为什么在 Kotlin JVM 中强制执行 GC 扫描时有些垃圾没有被收集,这取决于看似不相关的因素?

Why does some garbage not get collected when forcing a GC sweep in Kotlin JVM, depending on seemingly irrelevant factors?

上下文

我正在开发一个 Kotlin 程序,该程序 运行 在 JVM 上运行并消耗大量内存。虽然我确实相信垃圾收集器会(最终)释放不再可达的对象使用的内存,但我不相信项目的未来维护者(包括我未来的自己)——尤其是随着项目的进展和发展——到以确保不再需要的对象确实无法访问的方式编写代码。

因此,为了降低风险,作为我的测试套件的一部分(在程序功能逻辑方面已经详尽无遗)我也在编写(或尝试编写,取得了不同程度的成功) ) 各种测试,旨在确保引用不会保留到具有 运行 其进程的对象。

因为这很难直接做到,我在测试中使用的技术是使用带有终结器的对象,模拟不再需要它们时的条件,强制垃圾收集,并断言终结器有确实 运行。 (注意:我的问题不是关于这种技术本身,而是如果有人有意见或改进想法或可以提出替代方案 - 我会很想听听!)。

这通常效果很好,并且可以证明可以完成这项工作,例如以 TDD 风格:我编写了朴素的代码,就业务逻辑而言,它完成了工作,但没有处理丢失对旧对象的引用,我编写了如上所述的测试,我确保测试失败,我添加代码来处理内存(例如,在简单的情况下,将引用设置为 null),然后查看测试是否通过。

我的问题

出于某种原因,我的测试并不总是有效(澄清:我并不是说它们不确定地失败;我的意思是一些测试始终有效,而另一些则始终失败)。该项目的真实示例包含许多复杂的专有细节,但我设法将其归结为以下最小示例:

import kotlin.test.*

class FinalizationNotifier(val notify: () -> Unit) { protected fun finalize() = notify() }

class GcTest {
    @Test fun `disposes of no-longer-used object`() {
        var numTimesHasBeenDisposed = 0
        // The following line allocates a (FinalizationNotifier) object, but keeps no reference to it
        f(FinalizationNotifier { numTimesHasBeenDisposed++ }) // Note call to f, which in turn calls println
        assertEquals(0, numTimesHasBeenDisposed) // Finalizer has not been run yet
        System.gc() // Force garbage collection
        Thread.sleep(1) // seems to be necessary to make sure finalizers have been run
        assertEquals(1, numTimesHasBeenDisposed) // Finalizer has indeed been run
    }
}

fun<T> f(value: T) { println(value) }

上面写的测试通过了,但是如果我没有调用 f 而直接调用 println – 它失败了(在最后一个断言中,即终结器没有被 运行然而)!我的问题是,为什么 and/or 应该怎么做才能确保(收集垃圾和)终结器确实 运行.

(注意:这不是关于使用println或调用我自己的函数;我的真实代码要复杂得多,确实调用了我自己的函数,与打印无关。这只是我设法想出的一个最小示例显示了不一致。)

以防万一,我在 Java 11.0.6(在 macOS 上)上使用 Kotlin 1.5.10。

更新:

我已经并排编写了两个测试(在包 t 中的文件 T.kt 中);下面请找到源代码,以及反汇编(使用javap -c获得)。

package t

import kotlin.test.*

class FinalizationNotifier(val notify: () -> Unit) { protected fun finalize() = notify() }

class GcTest {
    @Test fun `disposes of no-longer-used object when calling own function`() {
        var numTimesHasBeenDisposed = 0
        f(FinalizationNotifier { numTimesHasBeenDisposed++ }) // Note call to f, which in turn calls println
        assertEquals(0, numTimesHasBeenDisposed) // Finalizer has not been run yet
        System.gc() // Force garbage collection
        Thread.sleep(1) // seems to be necessary to make sure finalizers have been run
        assertEquals(1, numTimesHasBeenDisposed) // Finalizer has indeed been run
    }

    @Test fun `disposes of no-longer-used object when calling println directly`() {
        var numTimesHasBeenDisposed = 0
        println(FinalizationNotifier { numTimesHasBeenDisposed++ }) // Note direct call to println
        assertEquals(0, numTimesHasBeenDisposed) // Finalizer has not been run yet
        System.gc() // Force garbage collection
        Thread.sleep(1) // seems to be necessary to make sure finalizers have been run
        assertEquals(1, numTimesHasBeenDisposed) // This fails for some reason
    }
}

fun<T> f(value: T) { println(value) }
public final class t.FinalizationNotifier {
  public t.FinalizationNotifier(kotlin.jvm.functions.Function0<kotlin.Unit>);
    Code:
       0: aload_1
       1: ldc           #10                 // String notify
       3: invokestatic  #16                 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
       6: aload_0
       7: invokespecial #19                 // Method java/lang/Object."<init>":()V
      10: aload_0
      11: aload_1
      12: putfield      #22                 // Field notify:Lkotlin/jvm/functions/Function0;
      15: return

  public final kotlin.jvm.functions.Function0<kotlin.Unit> getNotify();
    Code:
       0: aload_0
       1: getfield      #22                 // Field notify:Lkotlin/jvm/functions/Function0;
       4: areturn

  protected final void finalize();
    Code:
       0: aload_0
       1: getfield      #22                 // Field notify:Lkotlin/jvm/functions/Function0;
       4: invokeinterface #34,  1           // InterfaceMethod kotlin/jvm/functions/Function0.invoke:()Ljava/lang/Object;
       9: pop
      10: return
}
Compiled from "T.kt"
final class t.GcTest$disposes of no-longer-used object when calling own function extends kotlin.jvm.internal.Lambda implements kotlin.jvm.functions.Function0<kotlin.Unit> {
  final kotlin.jvm.internal.Ref$IntRef $numTimesHasBeenDisposed;

  t.GcTest$disposes of no-longer-used object when calling own function(kotlin.jvm.internal.Ref$IntRef);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
       5: aload_0
       6: iconst_0
       7: invokespecial #16                 // Method kotlin/jvm/internal/Lambda."<init>":(I)V
      10: return

  public final void invoke();
    Code:
       0: aload_0
       1: getfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
       4: getfield      #26                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
       7: istore_1
       8: aload_0
       9: getfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
      12: iload_1
      13: iconst_1
      14: iadd
      15: putfield      #26                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      18: return

  public java.lang.Object invoke();
    Code:
       0: aload_0
       1: invokevirtual #29                 // Method invoke:()V
       4: getstatic     #35                 // Field kotlin/Unit.INSTANCE:Lkotlin/Unit;
       7: areturn
}
Compiled from "T.kt"
final class t.GcTest$disposes of no-longer-used object when calling println directly extends kotlin.jvm.internal.Lambda implements kotlin.jvm.functions.Function0<kotlin.Unit> {
  final kotlin.jvm.internal.Ref$IntRef $numTimesHasBeenDisposed;

  t.GcTest$disposes of no-longer-used object when calling println directly(kotlin.jvm.internal.Ref$IntRef);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
       5: aload_0
       6: iconst_0
       7: invokespecial #16                 // Method kotlin/jvm/internal/Lambda."<init>":(I)V
      10: return

  public final void invoke();
    Code:
       0: aload_0
       1: getfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
       4: getfield      #26                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
       7: istore_1
       8: aload_0
       9: getfield      #13                 // Field $numTimesHasBeenDisposed:Lkotlin/jvm/internal/Ref$IntRef;
      12: iload_1
      13: iconst_1
      14: iadd
      15: putfield      #26                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      18: return

  public java.lang.Object invoke();
    Code:
       0: aload_0
       1: invokevirtual #29                 // Method invoke:()V
       4: getstatic     #35                 // Field kotlin/Unit.INSTANCE:Lkotlin/Unit;
       7: areturn
}
Compiled from "T.kt"
public final class t.GcTest {
  public t.GcTest();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: return

  public final void disposes of no-longer-used object when calling own function();
    Code:
       0: new           #14                 // class kotlin/jvm/internal/Ref$IntRef
       3: dup
       4: invokespecial #15                 // Method kotlin/jvm/internal/Ref$IntRef."<init>":()V
       7: astore_1
       8: new           #17                 // class t/FinalizationNotifier
      11: dup
      12: new           #19                 // class "t/GcTest$disposes of no-longer-used object when calling own function"
      15: dup
      16: aload_1
      17: invokespecial #22                 // Method "t/GcTest$disposes of no-longer-used object when calling own function"."<init>":(Lkotlin/jvm/internal/Ref$IntRef;)V
      20: checkcast     #24                 // class kotlin/jvm/functions/Function0
      23: invokespecial #27                 // Method t/FinalizationNotifier."<init>":(Lkotlin/jvm/functions/Function0;)V
      26: invokestatic  #33                 // Method t/TKt.f:(Ljava/lang/Object;)V
      29: iconst_0
      30: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      33: aload_1
      34: getfield      #43                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      37: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      40: aconst_null
      41: iconst_4
      42: aconst_null
      43: invokestatic  #49                 // Method kotlin/test/AssertionsKt.assertEquals$default:(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)V
      46: invokestatic  #54                 // Method java/lang/System.gc:()V
      49: lconst_1
      50: invokestatic  #60                 // Method java/lang/Thread.sleep:(J)V
      53: iconst_1
      54: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      57: aload_1
      58: getfield      #43                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      61: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      64: aconst_null
      65: iconst_4
      66: aconst_null
      67: invokestatic  #49                 // Method kotlin/test/AssertionsKt.assertEquals$default:(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)V
      70: return

  public final void disposes of no-longer-used object when calling println directly();
    Code:
       0: new           #14                 // class kotlin/jvm/internal/Ref$IntRef
       3: dup
       4: invokespecial #15                 // Method kotlin/jvm/internal/Ref$IntRef."<init>":()V
       7: astore_1
       8: new           #17                 // class t/FinalizationNotifier
      11: dup
      12: new           #65                 // class "t/GcTest$disposes of no-longer-used object when calling println directly"
      15: dup
      16: aload_1
      17: invokespecial #66                 // Method "t/GcTest$disposes of no-longer-used object when calling println directly"."<init>":(Lkotlin/jvm/internal/Ref$IntRef;)V
      20: checkcast     #24                 // class kotlin/jvm/functions/Function0
      23: invokespecial #27                 // Method t/FinalizationNotifier."<init>":(Lkotlin/jvm/functions/Function0;)V
      26: astore_2
      27: iconst_0
      28: istore_3
      29: getstatic     #70                 // Field java/lang/System.out:Ljava/io/PrintStream;
      32: aload_2
      33: invokevirtual #75                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
      36: iconst_0
      37: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      40: aload_1
      41: getfield      #43                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      44: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      47: aconst_null
      48: iconst_4
      49: aconst_null
      50: invokestatic  #49                 // Method kotlin/test/AssertionsKt.assertEquals$default:(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)V
      53: invokestatic  #54                 // Method java/lang/System.gc:()V
      56: lconst_1
      57: invokestatic  #60                 // Method java/lang/Thread.sleep:(J)V
      60: iconst_1
      61: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      64: aload_1
      65: getfield      #43                 // Field kotlin/jvm/internal/Ref$IntRef.element:I
      68: invokestatic  #39                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      71: aconst_null
      72: iconst_4
      73: aconst_null
      74: invokestatic  #49                 // Method kotlin/test/AssertionsKt.assertEquals$default:(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)V
      77: return
}
Compiled from "T.kt"
public final class t.TKt {
  public static final <T> void f(T);
    Code:
       0: iconst_0
       1: istore_1
       2: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
       5: aload_0
       6: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
       9: return
}

看起来,Kotlin 的 println(…) 函数与 Java 的 System.out.println(…) 语句在计算顺序方面具有不同的行为。

在Java时写

System.out.println(new Object() { {
    System.setOut(new PrintStream(OutputStream.nullOutputStream()));
}});

该消息仍将被打印,因为方法接收器 System.out 已被评估,即首先读取字段,然后再评估参数表达式,即内部 class object 创建并执行其构造函数。因此代码将首先更改 System.out,在打印之前使用更改前的值 System.out

因此,字节码看起来像

  0: getstatic     #35                 // Field java/lang/System.out:Ljava/io/PrintStream;
  3: new           #41                 // class my/test/EvaluationOrder
  6: dup
  7: invokespecial #43                 // Method my/test/EvaluationOrder."<init>":()V
 10: invokevirtual #44                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V

第一条指令将System.out的值压入操作数栈,然后是更复杂的object实例化序列,new创建并压入一个新的未初始化的object, dup复制引用,下面的invokespecial消耗一个并执行构造函数。

之后堆栈包含System.out的旧值,然后是对初始化object的引用,适合调用println方法。

Kotlin生成的字节码不同:

  8: new           #17                 // class t/FinalizationNotifier
 11: dup
 12: new           #65                 // class "t/GcTest$disposes of no-longer-used object when calling println directly"
 15: dup
 16: aload_1
 17: invokespecial #66                 // Method "t/GcTest$disposes of no-longer-used object when calling println directly"."<init>":(Lkotlin/jvm/internal/Ref$IntRef;)V
 20: checkcast     #24                 // class kotlin/jvm/functions/Function0
 23: invokespecial #27                 // Method t/FinalizationNotifier."<init>":(Lkotlin/jvm/functions/Function0;)V
 26: astore_2
 27: iconst_0
 28: istore_3
 29: getstatic     #70                 // Field java/lang/System.out:Ljava/io/PrintStream;
 32: aload_2
 33: invokevirtual #75                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V

您的用例有点复杂,因为构造函数调用接收到另一个新构造的 object(说明 12-20),但我们可以忽略这一点并专注于其他方面。

第一个引用的指令(编号 8)开始创建 object 您将在读取 System.out 时打印,作为 print(…) 函数的一部分, 在 之后 , 在指令 29.

中完成

为了按照 invokevirtual … println 所需的顺序引入这些引用,即 [PrintStream,参数 object],生成的代码引入了一个合成变量来保存对构造函数的引用object暂时。指令 26 astore_2 将对构造的 object 的引用存储到变量 #2 中。然后,如上所述,指令 29 将 System.out 的当前值压入堆栈⁽¹⁾,指令 32 aload_2 从变量 #2 加载对 object 的引用,这导致在指令 33.

中调用 println 所需的堆栈内容

一网打尽。现在,变量 #2 包含对 object 的引用,字节码没有作用域,也没有变量删除操作。引用保留在局部变量中,直到该变量因不同目的或方法 returns 被覆盖。当我们浏览剩余的代码时,我们看到在这个特定的设置中,变量 #2 没有再次使用,因此,当代码调用 System.gc() 时,引用仍然保存在合成变量中,可以防止垃圾collection.

Can java finalize an object when it is still in scope? 中所述,被局部变量引用并不能防止垃圾 collection 本身。正式地,object 仍然有资格垃圾 collection 当它不被使用超过那个点时。但它是否在实践中被垃圾收集,取决于方法的优化状态。因此,当此代码在您的场景中可重复地防止垃圾 collection 时,也就不足为奇了。

如果 Kotlin 支持使用具有与 Java 相同行为的 System.out.println(…) 而不是它的 built-in println(…) 函数,它应该改变行为。

⁽¹⁾ 无论出于何种原因,指令 27 和 28 将常量零存储在从未使用过的变量 #3 中。这不会影响其余代码的行为。

(元:回答我自己的问题,不是为了削弱已接受的答案,而是为了添加一些信息,这些信息可能对偶然发现类似问题并最终阅读本文的人们有用。)

原因

似乎 Kotlin 编译器有时会在后台引入局部变量来存储函数使用的各种值,原因可能对开发人员来说并不明显(事实上,我不知道为什么要这样做;如果有人知道,如果他们添加答案或评论会很酷)。一旦一个对象被局部变量引用,虽然理论上它在最后一次使用该变量后仍然可以是 garbage-collected,但实际上它似乎不是(至少在某些 set-ups,可能不是全部——例如参见 [​​=39=])。

诊断

诊断这种情况的一种方法是查看生成的 Java 字节码,例如通过使用 javap -c,或者 IDE 的 show bytecode 选项(如果有)(在 Intellij IDEA 上它位于 Tools -> Kotlin -> Show Kotlin Bytecode)。 注意:确保查看实际字节码或至少与项目设置一致的字节码。例如,默认情况下,Intellij 当前显示通过遗留 Kotlin 编译器生成的 Kotlin 字节码,而不是使用 Kotlin IR 的较新编译器,并且在某些情况下,这与通过 Kotlin IR 编译的实际代码 运行 不同(参见 issue)。

字节码将显示 store 局部变量指令,包括未在代码中明确定义但由编译器添加的变量。

例如,在问题中发布的字节码的以下摘录中:

  public final void disposes of no-longer-used object when calling println directly();
    Code:
       0: new           #14                 // class kotlin/jvm/internal/Ref$IntRef
       3: dup
       4: invokespecial #15                 // Method kotlin/jvm/internal/Ref$IntRef."<init>":()V
       7: astore_1
       8: new           #17                 // class t/FinalizationNotifier
      11: dup
      12: new           #65                 // class "t/GcTest$disposes of no-longer-used object when calling println directly"
      15: dup
      16: aload_1
      17: invokespecial #66                 // Method "t/GcTest$disposes of no-longer-used object when calling println directly"."<init>":(Lkotlin/jvm/internal/Ref$IntRef;)V
      20: checkcast     #24                 // class kotlin/jvm/functions/Function0
      23: invokespecial #27                 // Method t/FinalizationNotifier."<init>":(Lkotlin/jvm/functions/Function0;)V
      26: astore_2

可以看到 FinalizationNotifier 实例从 8: 开始初始化并在 26: 之前完成,然后 26: astore_2 将其保存在局部变量中(没有。 2;局部变量0号是this实例,这里1号是var numTimesHasBeenDisposed = 0,从0:开始初始化,存放在7:).

什么时候发生?

正如上面所说,我真的不知道为什么 Kotlin 编译器会这样做。而且当它发生时,我也远不知道所有的情况。但我确实注意到了两种不同的情况(一种来自问题中的最小示例,另一种来自我的实际用例):

将表达式传递给println时:这就是问题中示例中发生的情况。当直接调用 System.out.println 时,生成的字节码首先将 System.out 入栈,然后计算传递给它的表达式,将其留在栈中,然后调用 println 方法。但是当调用 Kotlin 的 println 时,首先计算传递给它的表达式,然后才计算——就好像 println 调用自身的一部分一样——字节码得到 System.out .如果不求助于将表达式的值存储在局部变量中,这两个元素最终会以与实际调用所需的顺序相反的顺序出现在堆栈中。 Kotlin 解决这个问题的一种方法可能是保留原始顺序(这在理论上可能会对语义产生一些影响,但我几乎看不出这在实践中会有什么不同)。另一个是 swap 堆栈上的值。但出于某种原因,Kotlin 使用了局部变量。

构造数组时:这是我实际用例中发生的情况。我正在将一个 ad-hoc 内置数组传递给一个函数(实际上是通过 vararg,但是在尝试了几种情况之后它似乎没有什么不同)。当不通过 Kotlin IR 编译时,数组是在堆栈上构造的,但是当编译 Kotlin IR 时,生成的字节码使用局部变量来存储数组。

如果阅读它的人在类似情况下遇到类似问题,这可能就是原因。并且可能还有其他情况。欢迎任何知道的人加入 answer/comment/edit。当然,你可以通过查看上面解释的字节码来诊断问题,不管你的情况是否在此处列出。

补救措施

我想出了三种不同的方法来解决这个问题:

  • 在使用println的特定情况下,可以直接调用System.out.println

  • 更一般地说,可以将创建附加变量的代码包装在另一个函数中(可以但不一定是嵌套函数)。这样,当包装函数的执行结束时,创建的局部变量将不再可访问,并且它们会得到 garbage-collected。 (这是原始问题中确实通过的一项测试所做的。)

  • 另一种选择是显式地将值赋给一个变量,而不是让 Kotlin 在幕后隐式地这样做,然后将其他东西赋给该变量(必须是 var ,而不是 val) 或改变值,如果它仅用作一个引用我们想要的实际对象的容器 garbage-collected (例如,如果它是一个数组,可以稍后清除它)。 (也可以使变量可为空,然后将 null 分配给它,但是,根据用例的具体情况,有时可能需要使用某种机制——例如 !! 运算符——以将值转换为 non-nullable 类型,这又是一个新表达式,最终可能会分配给隐式局部变量,从而破坏整个转换的目的。)