由一个Bug来看Java内存模型和垃圾回收

背景

前两天,项目中发现一个Bug。咱们使用的RocketMQ,在服务启动后会建立MQ的消费者实例。测试过程当中,发现服务启动一段时间后,与RocketMQ的链接就会断掉,从而找不到订阅关系,监听不到数据。bash

1、Bug的产生

通过回溯代码,发现订阅的逻辑是这样的。将ConsumerStarter类注册到Spring,并经过PostConstruct注解触发初始化方法,完成MQ消费者的建立和订阅。 微信

ConsumerStarter

上面代码中的Subscriber类是同事写的一个工具类,封装了一些链接RocketMQ的配置信息,使用的时候都调用这里。这里面也不复杂,就是链接RocketMQ,完成建立和订阅。app

Subscriber

一、finalize

上面的代码看起来平平无奇,但实际上他重写了finalize方法。而且在里面执行了consumer.shutdown(),将RocketMQ断开了,这里是诱因。工具

finalizeObject中的方法。在GC(垃圾回收器)决定回收一个不被其余对象引用的对象时调用。子类覆写 finalize 方法来处置系统资源或是负责清除操做。测试

回到项目中,他这样的写法就是在Subscriber类被回收的时候,断开RokcketMQ的链接,于是产生了Bug。最简单的方式就是把shutdown这句代码删掉,但这彷佛不是好的解决方案。this

二、为什么被回收?

在Java的内存模型中,有一个虚拟机栈,它是线程私有的。spa

虚拟机栈是线程私有的,每建立一个线程,虚拟机就会为这个线程建立一个虚拟机栈,虚拟机栈表示Java方法执行的内存模型,每调用一个方法就会为每一个方法生成一个栈帧(Stack Frame),用来存储局部变量表、操做数栈、动态连接、方法出口等信息。每一个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程。虚拟机栈的生命周期和线程是相同的.net

在上面的ConsumerStarter.init()方法中,Subscriber subscriber = new Subscriber()被定义成了局部变量,在方法执行完毕后,subscriber变量就没有了引用,而后GC掉。线程

很快,我就有了新的想法,将Subscriber定义成ConsumerStarter类中的成员变量也是能够的,由于ConsumerStarter是注册到了Spring中。在Bean的生命周期内,不会被回收。3d

如上代码,把subscriber变量做用域提到类级别,事实证实这样也是没问题的。

还有个更优的方案是,将Subscriber类直接注册到Spring中,由PostConstruct注解触发初始化完成对MQ的建立和订阅;由PreDestroy注解完成资源的释放。这样,资源的建立和销毁跟Bean的生命周期绑定,也是没问题的。

到目前为止,这个Bug的缘由和解决方案都有了。但还有个问题,笔者当时也没想明白。

2、线程与垃圾回收

为了肯定哪些对象是垃圾,在Java中使用了可达性分析的方法。

它经过经过一系列的GC roots对象做为起点搜索。从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证实此对象是不可用的。

在上面的例子中,虽然Subscriber类已经不被引用,要被回收,可是它里面还有RocketMQConsumer实例还在以线程的方式运行,这种状况下,Subscriber类也会被回收掉吗?

那么,就变成了这样一个问题: 若是一个对象A已不被引用,符合GC条件;可是它里面还有一个线程对象a1在活跃,那么此时这个对象A还会被回收吗?

为了验证这个问题,笔者作了一个测试。

一、测试一

在这里,仍是以Subscriber类为例,给它启动一个线程,看是否还会被GC。简化代码以下:

测试流程是:启动服务 > Subscriber类被建立 > 线程执行10次循环 > 手动调用GC

得出结果以下:

15:01:07.110  [main]  INFO  - Subscriber已启动...
15:01:07.112  [Thread-9]  INFO  - 执行线程次数:0
15: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 seconds
15:01:08.114  [Thread-9]  INFO  - 执行线程次数:1
15:01:09.114  [Thread-9]  INFO  - 执行线程次数:2
15:01:09.302  [http-nio-8081-exec-2]  INFO  - 调用GC。。。
15:01:10.122  [Thread-9]  INFO  - 执行线程次数:3
15:01:11.123  [Thread-9]  INFO  - 执行线程次数:4
15:01:12.123  [Thread-9]  INFO  - 执行线程次数:5
15:01:12.319  [http-nio-8081-exec-3]  INFO  - 调用GC。。。
15:01:13.123  [Thread-9]  INFO  - 执行线程次数:6
15:01:14.124  [Thread-9]  INFO  - 执行线程次数:7
15:01:15.125  [Thread-9]  INFO  - 执行线程次数:8
15:01:15.319  [http-nio-8081-exec-5]  INFO  - 调用GC。。。
15:01:16.125  [Thread-9]  INFO  - 执行线程次数:9
15:01:17.775  [http-nio-8081-exec-7]  INFO  - 调用GC。。。
15:01:17.847  [Finalizer]  INFO  - finalize-------------Subscriber对象被销毁
复制代码

从结果上看,若是Subscriber类若是有活跃的线程在运行,它是不会被回收的;等线程运行完以后,再次调用GC,才会被回收掉。不过,先不要急着下结论,咱们再测试一下别的状况。

二、测试二

此次,咱们先建立一个线程类Thread1

它的run方法,咱们跟上面保持一致。而后在Subscriber对象中经过线程调用它。

流程和测试1同样,此次的结果输出以下:

14:59:20.193  [main]  INFO  - Subscriber已启动...
14:59:20.194  [Thread-9]  INFO  - 执行次数:0
14: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 seconds
14:59:21.197  [Thread-9]  INFO  - 执行次数:1
14:59:22.198  [Thread-9]  INFO  - 执行次数:2
14:59:23.198  [Thread-9]  INFO  - 执行次数:3
14:59:24.198  [Thread-9]  INFO  - 执行次数:4
14:59:25.198  [Thread-9]  INFO  - 执行次数:5
14:59:26.198  [Thread-9]  INFO  - 执行次数:6
14:59:27.198  [Thread-9]  INFO  - 执行次数:7
14:59:28.199  [Thread-9]  INFO  - 执行次数:8
14:59:29.199  [Thread-9]  INFO  - 执行次数:9
复制代码

从结果上看,Subscriber建立以后就由于ConsumerStarter.init()方法执行完毕,而被销毁了,丝毫没有受线程的影响。

咦,这就有意思了。从逻辑上看,两个测试都是经过new Thread()开启了新的线程,为啥结果却不同呢?

立即就在微信联系了芋道源码大神,大神果真老道,他一眼指出:你这个应该是内部类的问题。

三、匿名内部类

咱们把目光回到测试1的代码中。

若是在方法中,这样建立一个线程,实际上建立了一个匿名内部类。可是,它有什么特殊之处吗?居然会阻止对象会正常回收。

咱们知道在内部类编译成功后,它会产生一个class文件,该class文件与外部类并非同一class文件。在上面的代码中,编译后就产生一个class文件叫作:Subscriber$1.class

经过反编译软件,咱们能够获得这个class文件的内容以下:

看到这里,就已经很明确了。这个内部类虽然与外部类不是同一个class文件,可是它保留了对外部类的引用,就是这个Subscriber this$0

只要内部类的方法执行不完,就会还保留外部类的引用实例,因此外部类就不会被GC回收。

因此,再回到一开始的问题:

若是一个对象A已不被引用,符合GC条件;可是它里面还有一个线程对象a1在活跃,那么此时这个对象A还会被回收吗?

答案也是确定的,除非线程对象a1还在引用A对象。

3、匿名内部类与垃圾回收

第二部分中,咱们看的是匿名内部类中开启线程和垃圾回收的关系,咱们再看看与线程无关的状况。

在乎识到是由于匿名内部类致使垃圾回收的问题时,在网上找到一篇文章。说的也是匿名内部类垃圾回收的问题,原文连接: 匿名内部类相关的gc

里面的代码笔者也测试过了,确实也如原文做者所说:

从结果推测,匿名内部类会关联对象,内部类对象不回收,致使主对象没法回收;

但实际上,这并非匿名内部类的问题,或者说不彻底是它的问题,而是static修饰符的问题。

一、测试一

使用匿名内部类咱们必需要继承一个父类或者实现一个接口,因此咱们随便建立一个接口。

public interface TestInterface {
    void out();
}
复制代码

而后在Subscriber外部类中使用它。

而后启动服务,输出结果以下:

16:37:53.626  [main]  INFO  - Root WebApplicationContext: initialization completed in 1867 ms
16: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变量定义成静态的呢?static TestInterface interface1;

输出结果以下:

16:43:20.331  [main]  INFO  - Root WebApplicationContext: initialization completed in 1826 ms
16: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 seconds
16: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 ms
16:43:33.140  [http-nio-8081-exec-1]  INFO  - 调用GC。。。
16:43:35.763  [http-nio-8081-exec-2]  INFO  - 调用GC。。。
复制代码

若是把interface1定义成静态的,再怎么调用GCSubscriber都不会被回收掉。

怎么办呢?能够在interface1使用完以后,把它从新赋值成空。interface1=null;,这时候,Subscriber类又会像测试1里面那样,被正常回收掉。

不过,这又是为何呢?Java的垃圾回收是依靠GC Roots开始向下搜索的,当一个对象到GC Roots没有任何引用链相连时,则证实此对象是不可用的;那么反之,就是可用的,不可回收的。

GC Roots包含如下对象:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI[即通常说的Native]引用的对象

也就是说,若是咱们把匿名内部类interface1定义成静态的对象,它就会看成GC Root对象存在,此时,这个内部类里面又保持着外部类Subscriber的引用,因此迟迟不能被回收。

二、测试二

为何说,这并非匿名内部类的问题,或者说不彻底是它的问题,而是static修饰符的问题,咱们能够再作一个测试。

关于匿名内部类,咱们先无论它是如何产生的,也不理会又是怎么用的,咱们只记住它的特性之一:

它会保持外部类的引用。

因此,咱们先摒弃什么劳什子内部类,就建立一个普通的类,让它保持调用类的引用。

而后在外部类Subscriber中,建立它的实例,并调用:

最后启动服务,输出结果以下:

17:30:02.331  [main]  INFO  - Root WebApplicationContext: initialization completed in 1726 ms
17:30:02.443  [main]  INFO  - Subscriber已启动...
com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a9
17:30:02.447  [main]  INFO  - User对象输出信息....
com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a9
17: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对象却保持着外部类的引用。

4、总结

本文经过一个小Bug,引起了内存模型和垃圾回收的问题。由于基础理论知识还不过硬,只能经过不一样的测试,来解释整个事件的缘由。

另外,虽然Java帮咱们自动回收垃圾,但你们写代码的时候注意不要引起内存泄露哦。

相关文章
相关标签/搜索