Java 中, 为什么一个对象的实例方法在执行完成之前其对象可以被 GC 回收?

执行以下代码, 可能会抛出 `java.util.concurrent.RejectedExecutionException` 异常: /** * -Xmx20m -Xms20m */ public class SingleThreadPoolTest { public static void main(String[] args) { for (int i = 0; i < 2000; i++) { newSingleThreadPool(); } } private static void newSingleThreadPool() { Executors.newSingleThreadExecutor().submit(new Runnable() { @Override public void run() { byte[] by…
关注者
295
被浏览
13663
收到好多邀请…泻药泻药。其中有好些感觉是想来吃瓜的。

所以请让我先跑个题,为吃瓜群众献上几个传送门:
这几个链接讲的都是.NET上实例方法的“this”的寿命可能比实例方法的调用周期要短的情况。
而题主遇到的Java的问题,正是上面几个.NET的链接一样的情况。

=======================================

吃完瓜了,我希望阅读这个回答的同学们要是嫌太长读不完的话,至少记住这两个方法:
就够了。它们都是用来保证传进去的引用类型参数在调用这俩方法的位置上一定存活用的,保证在这个位置上GC还不能回收掉传进去的引用所指向的对象。

Java 8或之前的话,其实自己想办法写个接收一个引用类型参数的方法,保证JIT编译器不要内联它就好了。既然涉及跟JIT编译器玩对抗,这就必然没有“跨JVM平台”的法子,而只能针对每个JVM实现去摸索办法。
在HotSpot VM上的话,用 -XX:CompileCommand=dontinline,fully/qualified/ClassName,methodName 参数即可保证某个方法不被内联。JMH里的blackhole()系方法就是这样实现的。

题主的例子,在Java 9里只要加上一个reachabilityFence()调用就没事了:
    private static void newSingleThreadPool() {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        executor.submit(new Runnable() {
            @Override
            public void run() {
                byte[] bytes = new byte[1024 * 1024 * 4];
                System.out.println(Thread.currentThread().getName());
            }
        });
        Reference.reachabilityFence(executor); // HERE
    }
而在Java 8或之前的Java版本里,建议像下面这样显式调用executor.shutdown()其实跟reachabilityFence()的目的是类似的,只是用了更绕弯的方式来达到目的而已:
    private static void newSingleThreadPool() {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        executor.submit(new Runnable() {
            @Override
            public void run() {
                byte[] bytes = new byte[1024 * 1024 * 4];
                System.out.println(Thread.currentThread().getName());
            }
        });
        executor.shutdown(); // HERE
    }

为啥说这两种看起来这么不同的写法实质的目的是一样的呢?且看下文的讲解。

=======================================

参数 / 局部变量的活跃区间

其实这事情很简单:因为一个成员方法里“this”参数的作用域虽然覆盖整个方法,但是其“活跃范围”(liveness)却不一定覆盖整个方法。在方法中的某个位置上,一个不再活跃的参数/局部变量就是就是死变量,GC就不会在乎它的值是多少而照样可以回收它所引用的对象。

先送上Java语言规范的一段:
Chapter 12. Execution
Optimizing transformations of a program can be designed that reduce the number of objects that are reachable to be less than those which would naively be considered reachable. For example, a Java compiler or code generator may choose to set a variable or parameter that will no longer be used to null to cause the storage for such an object to be potentially reclaimable sooner.
这说的就是一种可能让死变量变得对GC无效(给它们赋值为null只是一种比喻,虽说真要这么实现也可以…),使得tracing GC赖以判断对象生死的“可到达”(reachable)关系变得比源码字面上看要弱——栈帧上的值只有从活的参数 / 局部变量出发才算是根集合的一部分;还在作用域中但已经没有被使用(也就是死了)的参数 / 局部变量则根本没有作用。

请跳俩传送门:

一个参数 / 局部变量怎样才算在某个位置还活着呢?它必须在这个位置的后面还有活着的运算在使用它才算数。显然这个定义有递归,所以需要进一步定义“起始条件”。一种常见的做法是:一个函数的返回值,以及所有无法进一步分析副作用的运算,都要看作是活的。以它们为“根集合”顺着数据依赖关系向后推,就可以判断某个变量在某个位置是否还活着。
(对tracing GC有一定了解的同学,是不是觉得这种定义很眼熟?这跟tracing GC判断对象是否存活的思路是一样的:从一组肯定存活引用作为“根集合”,顺着对象的引用关系去遍历对象图中所有“可以到达”的对象并称之为“活的”。这里是同样的图论的应用,只是把引用关系换成了编译器中值的use-def关系而已。)

那么我们来看几个简单的例子。首先看这样的一个实例方法 foo()、bar() 和 incr():
public class Adder {
  private int val;

                          // this    a    b    c
  public int foo(int a) { // | d   | d
    int b = 42;           // | |   | |  | d
    int c = a + b;        // | |   |u/e |u/e | d
    return this.val + c;  // |u/e  |    |    |u/e
  }                       // |     |    |    |

                          // this    a    b    c
  public int bar(int a) { // |d/e  | d
    int b = 42;           // |     | |  | d
    int c = a + b;        // |     |u/e | u  | d
    return b + c;         // |     |    |u/e |u/e
  }                       // |     |    |    |

                               // this    newVal
  public Adder incr() {        // | d
    int newVal = this.val + 1; // | u     | d
    this.val = newVal;         // | u     |u/e
    return this;               // | u     |
  }                            // | e     |
}
这里我在注释里标注了参数 / 局部变量的作用域(scope)和活跃区间(live interval)的信息。记法为:每个参数 / 局部变量有两列标注,左边的列所示范围示作用域,右边的列所示范围则为活跃区间。其中活跃区间里用的标记简单说明就是:
  • d:def,全称definition,变量的“定义”(也就是一个变量获得一次赋值);
  • u:use,变量的“使用”(也就是变量参与一次运算);
  • e:end,标记活跃区间的结束;
  • |:表示本语句没有使用该变量,但该变量还在活跃区间内。
d/e表示在得到定义的地方就死掉了,u/e表示这里是最后一次use并且用完就死掉了。上面的incr()方法的表述有点特别,return this; 标记为 u 而不是 u/e,这是为了表示被返回的值不只是return语句的输入,而且要活到return语句之后(要返回给调用方)。

方法的参数都是在方法入口处得到定义的(从调用方传递而来),而普通局部变量则在显式赋值时得到定义。
显然,一个变量的活跃区间总是它的作用域的子集。在它最后一次被使用的位置之后,它就“死掉”了。

“this”作为实例方法的一个隐含参数,它的作用域是覆盖整个实例方法体没错,但它的活跃区间的判断方法与一般的参数 / 局部变量并无差别,同样可以在作用域结束前就死去。
对GC而言,在某个位置上如果“this”参数已经死掉了,“this”就可以不再被看作GC的根集合引用的一部分,于是“this”所引用的对象实例就可以被GC所回收。

作为引用类型的参数,“this”的常见“使用”点有:
  • 实例字段的读写:x = this.val、this.val = x
  • 实例方法的调用(作为隐藏参数传递给被调用方法):this.foo()
  • 方法调用的参数(作为显式参数传递给被调用方法):bar(this)
  • 锁:synchronized (this) { ... }
  • 方法的正常或异常返回:return this、throw this
(还有一些像拆箱、String in switch之类的场景也会“使用”引用,不过它们都可以看作上述场景的语法糖,所以就不单独拿出来说)
对号入座,看看自己的某个实例方法里有没有这么一些使用“this”的地方,在最后一个使用点之后,“this”就可以领便当收工了…

如果要被一个带优化的编译器来把某个Java方法编译到机器码的话,还得考虑这个编译器会不会做优化导致上述一些“使用点”被消除。于是优化后的代码中变量的活跃区间会比源码上看起来的作用域要更小。

广大Java程序员普遍觉得实例方法执行的时候其“this”应该一直活着才直观,而如果一个实例方法还在执行的时候其对象的finalize()方法就有可能同时执行是非常不直观的行为。
正因为如此,Java的核心开发团队的大大们也觉得说以后是不是要修改一下上文引用的Java语言规范的描述,让它保证“this”至少在实例方法执行的过程中要一直存活。换句话说,就是让每个实例方法都隐式在方法末尾添加一个对“this”的使用。未来的Java版本会不会真的修改这部分规定,现在还是未知数。但在添加了Reference.reachabilityFence()方法后,关于this活跃区间的规定可能短期内是不会写进规范里了。

上面引用的mechanical-sympathy讨论串里,有位大大给出了这样的一个例子:
ByteBuffer.allocateDirect(4096).put(ByteBuffer.allocate(4096));
这个例子也可以触发由于“this”的寿命可以比一个实例方法调用要短而造成的问题。大家有兴趣的话请自己分析一下看看?
(注意:Oracle JDK / OpenJDK的标准库实现中,每个DirectByteBuffer有一个关联的sun.misc.Cleaner对象(基于PhantomReference)来负责清理该buffer背后的native memory。这个Cleaner的作用跟finalizer很相似。)

不过,讲道理,只要一个“this”没有被某种弱引用盯着(finalizer、sun.misc.Cleaner都是基于弱引用的),其实“this”的寿命比实例方法短对于Java应用来说是完全看不出来区别的。
所以大家也不必太担心,该怎么写代码还怎么写,只要留心一下有基于弱引用的清理动作的地方要自己确保持强引用即可——不过当然咯,用别人写的库的时候也很可能不知道人家在底下有没有做基于弱引用的清理动作…

=======================================

可被finalize的对象

带有非空的finalize()方法的类会被JVM特殊处理:当GC发现这样的类的一个实例已经不再被任何活的强引用所引用时,就会把它放入finalizer queue,排队调用其finalize()方法。而当这样的一个实例的fianlize()方法被调用(并且该实例没有被复活)之后,下一次GC就可以把它看作普通对象来清理掉了。

所以一个带finalize()方法的类的实例,从已经失去所有强引用到真正被GC回收,通常要经历两次GC。

这是常识了,只是放在这里帮助同学们回忆一下有这么一回事。

=======================================

再看一组例子来帮助同学们热身。这跟题主最初举例问的问题不完全一致,但是简单一些好理解其性质。然后再去分析完整的例子就比较容易。

本例的关注点在于:从Test.foo()出发,为何能调用A.val()在里面能遇到NullPointerException。

public class Test {
  public static int foo() {
    return new A().val();
  }

  public static void main(String[] args) {
    for (int i = 0; i < 2000; i++) {
      try {
        foo();
      } catch (NullPointerException npe) {
        System.out.println("NPE happened at " + i + "th iteration");
        npe.printStackTrace(System.out);
        return;
      }
    }
  }
}

class A {
  private B b = new B();

  public void finalize() {
    this.b.clearC();
  }

  public int val() {
    return this.b.val();
  }
}

class B {
  private C c = new C();

  void clearC() {
    this.c = null;
  }

  public int val() {
    System.gc();              // the A instance is queued into finalizer queue
    System.runFinalization(); // A's finalize() is ensured to run
    return this.c.val();      // now this.c is null
  }
}

class C {
  public int val() {
    return 42;
  }
}
我在我的Mac OS X上跑Oracle JDK8u101,得到的结果是:
$ java Test
NPE happened at 257th iteration
java.lang.NullPointerException
	at B.val(Test.java:41)
	at A.val(Test.java:27)
	at Test.foo(Test.java:3)
	at Test.main(Test.java:9)
“按道理说”A.val()是个实例方法,它在执行的时候它的“this”肯定是活着的对不对?
——上面我们已经讨论过了,不对。“this”没用了就可以领便当了。此例正是如此。

这个例子有若干种方式可以触发到那个NPE,例如说既可以以Test.foo()为根来JIT编译,也可以以A.val()为根,都可以达到同样的效果。

首先,Test.foo()并没有持续持有A类实例的引用,在new A()之后就直接调用它上面的实例方法,把刚创建出来的A类实例的引用当作隐式“this”参数传递给A.val()了。所以在GC发生的时候,Test.foo()已甩锅——那个A类实例是活是死不关我事。

然后我们看看A.val()是怎么回事。
为了简化讨论,先来看完全不开内联时的情况。给启动参数加上 -XX:CompileCommand=dontinline,*,* 可以禁用所有方法的内联(除了intrinsic以外),然后加上 -XX:-BackgroundCompilation 会禁用HotSpot VM默认开启的后台编译功能——这样触发JIT编译后总是会等到编译结束再继续跑,而不是边继续在解释器里跑程序边等编译。禁用后台编译可以让实验输出结果更加稳定。
$ java -XX:CompileCommand=dontinline,*,* -XX:+PrintCompilation -XX:-BackgroundCompilation Test
   ...
   1230   76    b  3       A::val (8 bytes)
   1230   77    b  3       B::val (14 bytes)
   1230   78    b  3       java.lang.System::gc (7 bytes)
   1231   79     n 0       java.lang.Runtime::gc (native)   
   1234   80    b  3       java.lang.System::runFinalization (7 bytes)
   1234   81    b  3       java.lang.Runtime::runFinalization (4 bytes)
   1235   82     n 0       java.lang.Runtime::runFinalization0 (native)   (static)
   ...
   1242   97    b  3       A::finalize (8 bytes)
   1242   98    b  3       B::clearC (6 bytes)
NPE happened at 256th iteration
java.lang.NullPointerException
	at B.val(Test.java:41)
	at A.val(Test.java:27)
	at Test.foo(Test.java:3)
	at Test.main(Test.java:9)
可以发现这个实验总是到A.val()被JIT编译后就抛NPE。
还可以更激进地配置参数,只允许A.val()被JIT编译(也不允许A.val()内联任何其它方法),也可以达到同样的效果:
$ java -XX:CompileCommand=compileonly,A,val -XX:+PrintCompilation -XX:-BackgroundCompilation Test
CompilerOracle: compileonly A.val
   1038    1    b  3       A::val (8 bytes)
NPE happened at 256th iteration
java.lang.NullPointerException
	at B.val(Test.java:41)
	at A.val(Test.java:27)
	at Test.foo(Test.java:3)
	at Test.main(Test.java:9)

显然,A.val()在解释器里跑的时候,这个实验就不发生NPE。
这是因为在HotSpot VM的解释器里跑的时候,“this”参数总是在局部变量表里,而解释器不会对代码做活跃分析(liveness analysis),这个“this”参数会在A.val()解释执行过程中一直被当作是活的。而HotSpot VM的GC在扫描A.val()的解释器栈帧时,借助GenerateOopMap对代码做的分析又不做完整的活跃分析,导致GC把这个“this”看作一个根集合里的活的强引用。
  public int val();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0       
         1: getfield      #4                  // Field b:LB;
         4: invokevirtual #6                  // Method B.val:()I
         7: ireturn       
      LineNumberTable:
        line 27: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       8     0  this   LA;
附上 -XX:+TraceNewOopMapGenerationDetailed 的输出,可见“this”(在slot 0)确实在A.val()中从头到为都被解释器对应的GenerateOopMap认为是活的。
Iteration #0 of do_interpretation loop, method:
virtual jint A.val()

        0 vars     = ( r  |slot0)    aload_0
          stack    = 
          monitors = 
        1 vars     = ( r  |slot0)    getfield
          stack    = ( r  |slot0)
          monitors = 
        4 vars     = ( r  |slot0)    invokevirtual()I
          stack    = ( r  |line1)
          monitors = 
        7 vars     = ( r  |slot0)    ireturn
          stack    = (  v |Top)
          monitors = 
Report result pass for basicblock
        0 vars     = ( r  |slot0)    aload_0
          stack    = 
          monitors = 
        1 vars     = ( r  |slot0)    getfield
          stack    = ( r  |slot0)
          monitors = 
        4 vars     = ( r  |slot0)    invokevirtual()I
          stack    = ( r  |line1)
          monitors = 
        7 vars     = ( r  |slot0)    ireturn
          stack    = (  v |Top)
          monitors = 

但当 A.val() 被C1编译的时候,C1就会做正常的活跃分析,马上就知道在调用 B.val() 的时候,“this”参数其实已经死掉了,也就是说它不必再被看作是根集合中活着的强引用看待。
让我们看看C1在编译A.val()过程中,优化后的HIR是什么样子的:
__bci__use__tid____instr____________________________________
. 1    2    a2     a1._12 (L) b
. 4    0    a3     null_check(a2)
. 4    1    i4     a2.invokespecial()
                   B.val()I
. 7    0    i5     ireturn i4
用人话说,C1眼中的 A.val() 是这样的:(Java语法伪代码)
  public int val() {
    B a2 = this.b;     // last use of 'this'
    // 'this' already considered to be a dead variable by now

    if (a2 == null) throw new NullPointerException();
    int i4 = a2.val(); // GC will happen here
    return i4;
  }

于是在 A.val() 被C1编译后,等到 B.val() 被调用时,GC要扫描这个栈了,问大家哪些引用是活的,栈上的方法们说,在其栈帧中的参数 / 局部变量中:
^ System.gc() - 进VM执行GC  
| B.val()     - this: B 是活的
| A.val()     - 没有任何引用是活的(this: A 已死)
| Test.foo()  - 没有任何引用是活的,已甩锅
| Test.main() - 没有任何引用是活的
那GC就很高兴的发现A类实例已经没有被任何活引用指向了,就把它放进了finalizer queue。然后 B.val() 再调用个 System.runFinalization() 迫使 A.finalize() 被执行,然后…NPE就来了。

很简单对不对?

--------------------------------

然后再看看默认开内联的时候是什么状况。给启动参数加上-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining,可以看到这个方法会被HotSpot VM的C1编译,其内联树为:
    740   72    b  1       A::val (8 bytes)
                              @ 4   B::val (14 bytes)
                                @ 0   java.lang.System::gc (7 bytes)
                                  @ 0   java.lang.Runtime::getRuntime (4 bytes)
                                - @ 3   java.lang.Runtime::gc (0 bytes)   native method
                                @ 3   java.lang.System::runFinalization (7 bytes)
                                  @ 0   java.lang.Runtime::getRuntime (4 bytes)
                                  @ 3   java.lang.Runtime::runFinalization (4 bytes)
                                  - @ 0   java.lang.Runtime::runFinalization0 (0 bytes)   native method
                                @ 10   C::val (3 bytes)
可以看到,除了 Runtime.gc() 与 Runtime.runFinalization0() 两个native方法外,从A.val()出发调用的所有方法都被内联了。
经过内联后,A.val() 被C1编译出来的HIR长这样:
__bci__use__tid____instr____________________________________
. 1    2    a2     a1._12 (L) b
. 4    0    a3     null_check(a2)
  0    2    a10    <instance 0x00007f893a42b450 klass=java/lang/Class>
. 0    2    a11    a10._96 (L) currentRuntime
. 3    0    a13    null_check(a11)
. 3    0    v14    a11.invokespecial()
                   java/lang/Runtime.gc()V
. 0    1    a21    a10._96 (L) currentRuntime
. 3    0    a23    null_check(a21)
. 0    0    v26    invokestatic()
                   java/lang/Runtime.runFinalization0()V
. 7    1    a29    a2._12 (L) c
. 10   0    a30    null_check(a29)
  0    1    i33    42
. 7    0    i36    ireturn i33
用人话说,这C1 HIR的意思是:
  public int val() {
    B a2 = this.b;          // last use of 'this'
    // 'this' already considered to be a dead variable by now

    if (a2 == null) throw new NullPointerException();
    Class<Runtime> a10 = Runtime.class;
    Runtime a11 = a10.currentRuntime;
    if (a11 == null) throw new NullPointerException();
    a11.gc();               // native method, not inlined
                            // GC will happen here

    Runtime a21 = a10.currentRuntime;
    if (a21 == null) throw new NullPointerException();
    a21.runFinalization0(); // native method, not inlined
                            // ensure A's finalize() is run

    // Now a2.c is null because A's finalize() has run

    C a29 = a2.c;
    if (a29 == null) throw new NullPointerException();
    int i33 = 42;
    return i33;
  }
同学们试试用前面说的简易分析方法,看看这里 A.val() 里的“this”是不是在调用GC之前就已经死掉了?具体分析过程就留作课后习题吧。

=======================================

题主的例子的分析

有了上面的基础,相信大家也不难自己去分析一下了。
关键点在于:
  • 题主写的 SingleThreadPoolTest.newSingleThreadPool() 并没有有效地持有 Executors.newSingleThreadExecutor() 返回来的 ExecutorService 对象的引用,而是一甩锅,直接调用了 submit() 方法。这就跟上面的 Test.foo() 类似。
  • Executors.newSingleThreadExecutor() 返回来的是一个带 finalize() 方法的对象,具体来说是一个 Executors$FinalizableDelegatedExecutorService 类的实例。它内部包装着真正负责实现功能的 ThreadPoolExecutor 类的实例。这俩的关系就跟 A 与 B 类的关系类似。
  • 题主调用的 submit() 方法是外层包装的 Executors$FinalizableDelegatedExecutorService 上的版本,具体实现是 Executors$DelegatedExecutorService.submit() 。它就跟上面的 A.val() 与 B.val() 的关系非常相似,在解释器里能保持“this”的存活,但一旦被JIT编译就不能保持“this”的存活。
  • 于是在 Executors$DelegatedExecutorService.submit() 被JIT编译后,即便在它还在执行的时候,GC也可以把外层包装的 Executors$FinalizableDelegatedExecutorService 放进finalizer queue里,进而调用其finalize()方法。这么一来,内层被包装的 ThreadPoolExecutor 就被 shutdown() 了,于是就会触发题主看到的那个 RejectedExecutionException 异常。

就这样而已。本来还想就这个例子详细展开写写分析过程的,但感觉也没有必要了。这里就发一点分析内容吧,C1 编译 SingleThreadPoolTest.newSingleThreadPool() 优化后的内联树,以及HIR的样子:
内联树:
   1092  176    b  1       SingleThreadPoolTest::newSingleThreadPool (17 bytes)
                              @ 0   java.util.concurrent.Executors::newSingleThreadExecutor (28 bytes)                                @ 18   java.util.concurrent.LinkedBlockingQueue::<init> (7 bytes)
                                  @ 3   java.util.concurrent.LinkedBlockingQueue::<init> (94 bytes)   callee is too large
                                @ 21   java.util.concurrent.ThreadPoolExecutor::<init> (18 bytes)                                  @ 8   java.util.concurrent.Executors::defaultThreadFactory (8 bytes)
                                    @ 4   java.util.concurrent.Executors$DefaultThreadFactory::<init> (75 bytes)   callee is too large                                  @ 14   java.util.concurrent.ThreadPoolExecutor::<init> (143 bytes)   callee is too large                                @ 24   java.util.concurrent.Executors$FinalizableDelegatedExecutorService::<init> (6 bytes)                                  @ 2   java.util.concurrent.Executors$DelegatedExecutorService::
<init> (10 bytes)
                                    @ 1   java.util.concurrent.AbstractExecutorService::<init> (5
 bytes)
                                      @ 1   java.lang.Object::<init> (1 bytes)
                              @ 7   SingleThreadPoolTest$1::<init> (5 bytes)
                                @ 1   java.lang.Object::<init> (1 bytes)
                              @ 10   java.util.concurrent.Executors$DelegatedExecutorService::submit (11 bytes)
                                @ 5   java.util.concurrent.AbstractExecutorService::submit (26 bytes)
                                  @ 15   java.util.concurrent.AbstractExecutorService::newTaskFor (10 bytes)
                                    @ 6   java.util.concurrent.FutureTask::<init> (19 bytes)
                                      @ 1   java.lang.Object::<init> (1 bytes)
                                    - @ 7   java.util.concurrent.Executors::callable (22 bytes)   callee is too large
                                - @ 21   java.util.concurrent.ThreadPoolExecutor::execute (132 bytes)   callee is too large
可见它没有内联 ThreadPoolExecutor.execute() 的调用。

HIR:
_p__bci__use__tid__result____instruction________________________________________ (HIR)
 .  0    2    a3   [R177|L]  new instance java/util/concurrent/Executors$FinalizableDelegatedExecutorService
 .  4    3    a4   [R178|L]  new instance java/util/concurrent/ThreadPoolExecutor
    8    2    i5             1
    10   1    l7             0L
    11   1    a9             <object 0x00007fd3f000f1c8 klass=java/util/concurrent/TimeUnit$3>
 .  14   2    a10  [R179|L]  new instance java/util/concurrent/LinkedBlockingQueue
    1    1    i13            2147483647
 .  3    0    v14            a10.invokespecial(i13)
                             java/util/concurrent/LinkedBlockingQueue.<init>(I)V
 .  0    2    a20  [R180|L]  new instance java/util/concurrent/Executors$DefaultThreadFactory
 .  4    0    v21            a20.invokespecial()
                             java/util/concurrent/Executors$DefaultThreadFactory.<init>()V
    11   1    a24            <object 0x00007fd3f000f1d8 klass=java/util/concurrent/ThreadPoolExecutor$AbortPolicy>
 .  14   0    v25            a4.invokespecial(i5, i5, l7, a9, a10, a20, a24)
                             java/util/concurrent/ThreadPoolExecutor.<init>(IIJLjava/util/concurrent/TimeUnit;Ljava/util/concurrent/BlockingQueue;Ljava/util/concurrent/ThreadFactory;Ljava/util/concurrent/RejectedExecutionHandler;)V
 .  0    0    v35            Object.Object_init(a3)
 .  6    0    a38            a3._12 := a4 (L) e
 .  9    0    v39            membar_storestore
 .  3    1    a43  [R183|L]  new instance SingleThreadPoolTest$1
    1    1    a57            null
 .  0    3    a63  [R184|L]  new instance java/util/concurrent/FutureTask
 .  7    1    a69  [R185|L]  invokestatic(a43, a57)
                             java/util/concurrent/Executors.callable(Ljava/lang/Runnable;Ljava/lang/Object;)Ljava/util/concurrent/Callable;
 .  10   0    a70            a63._16 := a69 (L) callable
    14   1    i71            0
 .  15   0    i72            a63._12 := i71 (I) state
 .  21   0    v75            a4.invokespecial(a63)
                             java/util/concurrent/ThreadPoolExecutor.execute(Ljava/lang/Runnable;)V
 .  16   0    v78            return
其实也很简单了,有没有?

在最后那个没有被内联的 ThreadPoolExecutor.execute() 的调用点上,这个编译单元只认为一个引用还是活着的,那就是包装传入的Runnable参数的FutureTask实例的引用。所以GC在这里发生的话,外层的 Executors$FinalizableDelegatedExecutorService 就要被finalize了,进而引发题主所关心的那个异常。

就先写这么多。以上~