背景
前两天,项目中发现一个Bug。我们使用的 RocketMQ,在服务启动后会创建 MQ的消费者实例。测试过程中,发现服务启动一段时间后,与 RocketMQ的连接就会断掉,从而找不到订阅关系,监听不到数据。
一、Bug的产生
经过回溯代码,发现订阅的逻辑是这样的。将 ConsumerStarter类注册到Spring,并通过 PostConstruct注解触发初始化方法,完成 MQ消费者的创建和订阅。
上面代码中的 Subscriber类是同事写的一个工具类,封装了一些连接 RocketMQ的配置信息,使用的时候都调用这里。这里面也不复杂,就是连接 RocketMQ,完成创建和订阅。
1、finalize
上面的代码看起来平平无奇,但实际上他重写了 finalize方法。并且在里面执行了 consumer.shutdown(),将 RocketMQ断开了,这里是诱因。
finalize是 Object中的方法。在GC(垃圾回收器)决定回收一个不被其他对象引用的对象时调用。子类覆写 finalize 方法来处置系统资源或是负责清除操作。
回到项目中,他这样的写法就是在 Subscriber类被回收的时候,断开 RokcketMQ的连接,因而产生了Bug。最简单的方式就是把 shutdown这句代码删掉,但这似乎不是好的解决方案。
2、为何被回收?
在Java的内存模型中,有一个 虚拟机栈,它是线程私有的。虚拟机栈是线程私有的,每创建一个线程,虚拟机就会为这个线程创建一个虚拟机栈,虚拟机栈表示Java方法执行的内存模型,每调用一个方法就会为每个方法生成一个栈帧(Stack Frame),用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程。虚拟机栈的生命周期和线程是相同的
在上面的 ConsumerStarter.init()方法中, Subscribersubscriber=newSubscriber()被定义成了局部变量,在方法执行完毕后, subscriber变量就没有了引用,然后GC掉。
很快,我就有了新的想法,将 Subscriber定义成 ConsumerStarter类中的成员变量也是可以的,因为 ConsumerStarter是注册到了 Spring中。在Bean的生命周期内,不会被回收。
如上代码,把 subscriber变量作用域提到类级别,事实证明这样也是没问题的。
还有个更优的方案是,将 Subscriber类直接注册到 Spring中,由 PostConstruct注解触发初始化完成对MQ的创建和订阅;由 PreDestroy注解完成资源的释放。这样,资源的创建和销毁跟Bean的生命周期绑定,也是没问题的。
到目前为止,这个Bug的原因和解决方案都有了。但还有个问题,笔者当时也没想明白。
二、线程与垃圾回收
为了确定哪些对象是垃圾,在Java中使用了可达性分析的方法。
它通过通过一系列的 GC roots对象作为起点搜索。从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在上面的例子中,虽然 Subscriber类已经不被引用,要被回收,但是它里面还有 RocketMQ的 Consumer实例还在以线程的方式运行,这种情况下, Subscriber类也会被回收掉吗?
那么,就变成了这样一个问题: 如果一个对象A已不被引用,符合GC条件;但是它里面还有一个线程对象a1在活跃,那么此时这个对象A还会被回收吗?
为了验证这个问题,笔者做了一个测试。
1、测试一
在这里,还是以 Subscriber类为例,给它启动一个线程,看是否还会被GC。简化代码如下:
测试流程是: 启动服务>Subscriber类被创建>线程执行10次循环>手动调用GC
得出结果如下:15:01:07.110 [main] INFO - Subscriber已启动...15:01:07.112 [Thread-9] INFO - 执行线程次数:015:01:07.357 [main] INFO - Initializing ExecutorService 'applicationTaskExecutor'15:01:07.568 [main] INFO - Starting ProtocolHandler ["http-nio-8081"]15:01:07.609 [main] INFO - Tomcat started on port(s): 8081 (http) with context path ''15:01:07.614 [main] INFO - Started BootMybatisApplication in 3.654 seconds15:01:08.114 [Thread-9] INFO - 执行线程次数:115:01:09.114 [Thread-9] INFO - 执行线程次数:215:01:09.302 [http-nio-8081-exec-2] INFO - 调用GC。。。15:01:10.122 [Thread-9] INFO - 执行线程次数:315:01:11.123 [Thread-9] INFO - 执行线程次数:415:01:12.123 [Thread-9] INFO - 执行线程次数:515:01:12.319 [http-nio-8081-exec-3] INFO - 调用GC。。。15:01:13.123 [Thread-9] INFO - 执行线程次数:615:01:14.124 [Thread-9] INFO - 执行线程次数:715:01:15.125 [Thread-9] INFO - 执行线程次数:815:01:15.319 [http-nio-8081-exec-5] INFO - 调用GC。。。15:01:16.125 [Thread-9] INFO - 执行线程次数:915:01:17.775 [http-nio-8081-exec-7] INFO - 调用GC。。。15:01:17.847 [Finalizer] INFO - finalize-------------Subscriber对象被销毁
从结果上看,如果 Subscriber类如果有活跃的线程在运行,它是不会被回收的;等线程运行完之后,再次调用GC,才会被回收掉。不过,先不要急着下结论,我们再测试一下别的情况。
2、测试二
这次,我们先创建一个线程类 Thread1。
它的run方法,我们跟上面保持一致。然后在 Subscriber对象中通过线程调用它。
流程和测试1一样,这次的结果输出如下:14:59:20.193 [main] INFO - Subscriber已启动...14:59:20.194 [Thread-9] INFO - 执行次数:014:59:20.359 [Finalizer] INFO - finalize-------------Subscriber对象被销毁14:59:20.444 [main] INFO - Initializing ExecutorService 'applicationTaskExecutor'14:59:20.699 [main] INFO - Starting ProtocolHandler ["http-nio-8081"]14:59:20.745 [main] INFO - Tomcat started on port(s): 8081 (http) with context path ''14:59:20.751 [main] INFO - Started BootMybatisApplication in 3.453 seconds14:59:21.197 [Thread-9] INFO - 执行次数:114:59:22.198 [Thread-9] INFO - 执行次数:214:59:23.198 [Thread-9] INFO - 执行次数:314:59:24.198 [Thread-9] INFO - 执行次数:414:59:25.198 [Thread-9] INFO - 执行次数:514:59:26.198 [Thread-9] INFO - 执行次数:614:59:27.198 [Thread-9] INFO - 执行次数:714:59:28.199 [Thread-9] INFO - 执行次数:814:59:29.199 [Thread-9] INFO - 执行次数:9
从结果上看, Subscriber创建之后就因为 ConsumerStarter.init()方法执行完毕,而被销毁了,丝毫没有受线程的影响。
咦,这就有意思了。从逻辑上看,两个测试都是通过 newThread()开启了新的线程,为啥结果却不一样呢?
当即就在微信联系了芋道源码大神,大神果然老道,他一眼指出:你这个应该是内部类的问题。
3、匿名内部类
我们把目光回到测试1的代码中。
如果在方法中,这样创建一个线程,实际上创建了一个匿名内部类。但是,它有什么特殊之处吗?竟然会阻止对象会正常回收。
我们知道在内部类编译成功后,它会产生一个class文件,该class文件与外部类并不是同一class文件。在上面的代码中,编译后就产生一个class文件叫做: Subscriber$1.class。
通过反编译软件,我们可以得到这个class文件的内容如下:
看到这里,就已经很明确了。这个内部类虽然与外部类不是同一个class文件,但是它保留了对外部类的引用,就是这个 Subscriberthis$0。
只要内部类的方法执行不完,就会还保留外部类的引用实例,所以外部类就不会被GC回收。
所以,再回到一开始的问题:如果一个对象A已不被引用,符合GC条件;但是它里面还有一个线程对象a1在活跃,那么此时这个对象A还会被回收吗?
答案也是肯定的,除非线程对象a1还在引用A对象。
三、匿名内部类与垃圾回收
第二部分中,我们看的是匿名内部类中开启线程和垃圾回收的关系,我们再看看与线程无关的情况。
在意识到是因为匿名内部类导致垃圾回收的问题时,在网上找到一篇文章。说的也是匿名内部类垃圾回收的问题,原文链接: 匿名内部类相关的gc
里面的代码笔者也测试过了,确实也如原文作者所说:从结果推测,匿名内部类会关联对象,内部类对象不回收,导致主对象无法回收;
但实际上,这并不是匿名内部类的问题,或者说不完全是它的问题,而是 static修饰符的问题。
1、测试一
使用匿名内部类我们必须要继承一个父类或者实现一个接口,所以我们随便创建一个接口。public interface TestInterface { void out();}
然后在 Subscriber外部类中使用它。
然后启动服务,输出结果如下:16:37:53.626 [main] INFO - Root WebApplicationContext: initialization completed in 1867 ms16:37:53.710 [main] INFO - Subscriber已启动...16:37:53.711 [main] INFO - 匿名内部类测试...16:37:53.866 [Finalizer] INFO - finalize-------------Subscriber对象被销毁16:37:53.950 [main] INFO - Initializing ExecutorService 'applicationTaskExecutor'16:37:54.180 [main] INFO - Starting ProtocolHandler ["http-nio-8081"]16:37:54.224 [main] INFO - Tomcat started on port(s): 8081 (http) with context path ''
从结果来看, Subscriber对象被创建,使用完之后,不需要手动调用 GC,就被销毁了。
如果将 interface1变量定义成静态的呢? staticTestInterfaceinterface1;
输出结果如下:16:43:20.331 [main] INFO - Root WebApplicationContext: initialization completed in 1826 ms16:43:20.404 [main] INFO - Subscriber已启动...16:43:20.405 [main] INFO - 匿名内部类测试...16:43:20.673 [main] INFO - Initializing ExecutorService 'applicationTaskExecutor'16:43:20.955 [main] INFO - Starting ProtocolHandler ["http-nio-8081"]16:43:21.002 [main] INFO - Tomcat started on port(s): 8081 (http) with context path ''16:43:21.007 [main] INFO - Started BootMybatisApplication in 3.327 seconds16:43:33.095 [http-nio-8081-exec-1] INFO - Initializing Servlet 'dispatcherServlet'16:43:33.102 [http-nio-8081-exec-1] INFO - Completed initialization in 7 ms16:43:33.140 [http-nio-8081-exec-1] INFO - 调用GC。。。16:43:35.763 [http-nio-8081-exec-2] INFO - 调用GC。。。
如果把 interface1定义成静态的,再怎么调用 GC, Subscriber都不会被回收掉。
怎么办呢?可以在 interface1使用完之后,把它重新赋值成空。 interface1=null;,这时候, Subscriber类又会像测试1里面那样,被正常回收掉。
不过,这又是为什么呢?Java的垃圾回收是依靠 GCRoots开始向下搜索的,当一个对象到 GCRoots没有任何引用链相连时,则证明此对象是不可用的;那么反之,就是可用的,不可回收的。
GCRoots包含以下对象:虚拟机栈中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI[即一般说的Native]引用的对象
也就是说,如果我们把匿名内部类 interface1定义成静态的对象,它就会当作 GCRoot对象存在,此时,这个内部类里面又保持着外部类 Subscriber的引用,所以迟迟不能被回收。
2、测试二
为什么说,这并不是匿名内部类的问题,或者说不完全是它的问题,而是static修饰符的问题,我们可以再做一个测试。
关于匿名内部类,我们先不管它是如何产生的,也不理会又是怎么用的,我们只记住它的特性之一:
它会保持外部类的引用。
所以,我们先摒弃什么劳什子内部类,就创建一个普通的类,让它保持调用类的引用。
然后在外部类 Subscriber中,创建它的实例,并调用:
最后启动服务,输出结果如下:17:30:02.331 [main] INFO - Root WebApplicationContext: initialization completed in 1726 ms17:30:02.443 [main] INFO - Subscriber已启动...com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a917:30:02.447 [main] INFO - User对象输出信息....com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a917:30:02.605 [Finalizer] INFO - finalize-------------Subscriber对象被销毁17:30:02.606 [Finalizer] INFO - finalize-------------User对象被销毁17:30:02.676 [main] INFO - Initializing ExecutorService 'applicationTaskExecutor'17:30:02.880 [main] INFO - Starting ProtocolHandler ["http-nio-8081"]
这种情况下,都是会正常被回收的。
但如果这时候再把 user对象定义成静态属性,不管怎么 GC,还是不能回收。
由此看来,可以得出这样一个结论:匿名内部类并不会妨碍外部类的正常GC,而是不能将它定义成静态属性引用。
静态匿名内部类,导致外部类不能正常回收的原因就是:它作为GC Root对象却保持着外部类的引用。
四、总结
本文通过一个小Bug,引发了内存模型和垃圾回收的问题。因为基础理论知识还不过硬,只能通过不同的测试,来解释整个事件的原因。