转载

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

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

一、Bug的产生

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

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

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

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

1、finalize

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

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

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

2、为何被回收?

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

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

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

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

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

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

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

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

二、线程与垃圾回收

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

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

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

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

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

1、测试一

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

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

测试流程是: 启动服务 > 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,才会被回收掉。 不过,先不要急着下结论,我们再测试一下别的情况。

2、测试二

这次,我们先创建一个线程类 Thread1

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

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

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

流程和测试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() 开启了新的线程,为啥结果却不一样呢?

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

3、匿名内部类

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

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

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

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

通过反编译软件,我们可以得到这个class文件的内容如下:

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

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

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

所以,再回到一开始的问题:

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

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

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

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

在意识到是因为匿名内部类导致垃圾回收的问题时,在网上找到一篇文章。说的也是匿名内部类垃圾回收的问题,原文链接:匿名内部类相关的gc

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

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

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

1、测试一

使用匿名内部类我们必须要继承一个父类或者实现一个接口,所以我们随便创建一个接口。

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

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

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

然后启动服务,输出结果如下:

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 的引用,所以迟迟不能被回收。

2、测试二

为什么说, 这并不是匿名内部类的问题,或者说不完全是它的问题,而是static修饰符的问题 ,我们可以再做一个测试。

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

它会保持外部类的引用。

所以,我们先摒弃什么劳什子内部类,就创建一个普通的类,让它保持调用类的引用。

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

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

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

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

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对象却保持着外部类的引用。

四、总结

本文通过一个小Bug,引发了内存模型和垃圾回收的问题。因为基础理论知识还不过硬,只能通过不同的测试,来解释整个事件的原因。

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

原文  https://juejin.im/post/5ce78cb2518825332b59f6ee
正文到此结束
Loading...