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

背景

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

一、Bug的产生

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

249c6a02749b597b55853e628d651b82.png

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

2aa19147d98e006f9b883399dfaabcea.png

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。简化代码如下:

aee207150aa867101d5caa55f9259e2e.png

测试流程是: 启动服务>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。

bd5e950936212f12bd447c93d8b0b51a.png

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

a6f346cc0d6593014264abeafa19f05a.png

流程和测试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修饰符的问题,我们可以再做一个测试。

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

它会保持外部类的引用。

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

a88fd7a53cf21fa34b9743f8863e5fb3.png

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

3763c3104758b59744cc368c3cccc4b5.png

最后启动服务,输出结果如下: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,引发了内存模型和垃圾回收的问题。因为基础理论知识还不过硬,只能通过不同的测试,来解释整个事件的原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值