【求解惑】由一个Bug来看Java内存模型和垃圾回收

背景

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

一、Bug的产生

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

ConsumerStarter

上面代码中的Subscriber类是同事写的一个工具类,订阅的时候都调用这里。这里面也不复杂,就是调用RocketMQ,完成创建和订阅。

Subscriber

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定义成ConsumerStarter类中的成员变量也是可以的,因为ConsumerStarter是注册到了Spring中。在Bean的生命周期内,不会被回收。

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

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

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

二、疑问点

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

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

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。

  • 方法区中类静态属性引用的对象。

  • 方法区中常量引用的对象。

  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

结合代码来看,虚拟机栈中引用的对象是subscriber,而subscriber对象中又包含了Consumer对象。Consumer对象是在RocketMQ中创建的,并且调用了它的consumer.start方法。

我大概看了下RocketMQ,作为一个Consumer实例,它肯定会定期从 Name Server 拉取消息;并且定时向服务器发生心跳。而且在RocketMQ代码中,我也看到了ScheduledExecutorService这种定时器的启动。

那么,这一切说明,subscriber类的consumer的实例是活跃的呀,它们之间是可达的,不应该被回收吧?

这个问题也可以被描述成:如果A对象没有了引用,是确定可以被回收的比如局部变量subscriber,方法执行完应该就被销毁;但是如果A对象中还有线程在活跃,比如在活跃的线程是consumer实例,此时A对象还会被回收吗?

此处可能逻辑是错误的,也是笔者没能理解的地方。望大佬指正、解惑。

然后,基于上面的问题,笔者又做了两个测试。

回到上面项目中的代码,此时我还是将Subscriber定义成局部变量,这样在GC的时候,它还是要被回收的。在这里,可以通过System.gc();来手动触发GC。

1、在Subscriber类中新建线程

Subscriber类中,通过new Thread().start();的方式来创建一个线程并调用它的启动方法,整体代码如下:

如果是这种情况,当触发GC的时候,Subscriber类不会被回收,finalize方法也没有被调用,线程还会持续输出。

2、在Subscriber类中调用其他线程类

首先定义一个线程类MyThread1,它的run方法也是死循环。

然后在Subscriber类中通过MyThread1 thread1 = new MyThread1();实例化。

然后通过new Thread(thread1).start();来启动它。

此时,如果触发GC,Subscriber类照样会被回收,finalize方法也会被调用,但thread1线程仍然还会持续输出。

通过这两个测试,我更不太明白了。都是在Subscriber类中启动新的线程,为什么结果却不同呢?

是因为在测试1中,本类的线程还未执行结束,方法未结束吗?

请大佬们带着批判的目光审视第二部分,其中逻辑可能有误,请大佬们不吝赐教。如果一两句话扯不清楚,也希望有大佬可以专门写篇文章讲讲这里面的逻辑误区~

猜你喜欢

转载自juejin.im/post/5ce4e45af265da1b70047f5f