JVM源码分析之警惕存在内存泄漏风险的FinalReference(增强版)

概述

JAVA对象引用体系除了强引用之外,出于对性能、可扩展性等方面考虑还特地实现了四种其他引用:SoftReference、WeakReference、PhantomReference、FinalReference,本文主要想讲的是FinalReference,因为我们在使用内存分析工具比如mat等在分析一些oom的heap的时候,经常能看到 java.lang.ref.Finalizer占用的内存大小远远排在前面(其实通过jmap -histo就能发现,如下图所示),而这个类占用的内存大小又和我们这次的主角FinalReference有着密不可分的关系。

对于FinalReference及关联的内容,我们可能有如下印象:

  • 自己代码里从没有使用过
  • 线程dump之后,我们能看到一个叫做Finalizer的java线程
  • 偶尔能注意到java.lang.ref.Finalizer的存在
  • 我们在类里可能会写finalize方法

那FinalReference到底存在的意义是什么,以怎样的形式和我们的代码相关联呢,这是本文要理清的问题。

JDK中的FinalReference

首先我们看看FinalReference在JDK里的实现:

大家应该注意到了类访问权限是package的,这也就意味着我们不能直接去对其进行扩展,但是JDK里对此类进行了扩展实现java.lang.ref.Finalizer,这个类也是我们在概述里提到的,而此类的访问权限也是package的,并且是final的,意味着真的不能被扩展了,接下来的重点我们围绕java.lang.ref.Finalizer展开(PS:后续讲Finalizer相关的其实也就是在说FinalReference)

Finalizer的构造函数

从构造函数上我们获得下面的几个关键信息

private:意味着我们在外面无法自己构建这类对象

finalizee参数:FinalReference指向的对象引用

调用add方法:将当前对象插入到Finalizer对象链里,链里的对象和Finalizer类静态相关联,言外之意是在这个链里的对象都无法被gc掉,除非将这种引用关系剥离掉(因为Finalizer类无法被unload)。

虽然外面无法创建Finalizer对象,但是注意到有一个register的静态方法,在方法里会创建这种对象,同时将这个对象加入到Finalizer对象链里,这个方法是被vm调用的,那么问题来了,vm在什么情况下会调用这个方法呢?

Finalizer对象何时被注册到Finalizer对象链里

类其实有挺多的修饰,比如final,abstract,public等等,如果一个类有final修饰,我们就说这个类是一个final类,上面列的都是语法层面我们可以显示标记的,在jvm里其实还给类标记其他一些符号,比如finalizer,表示这个类是一个finalizer类(为了和java.lang.ref.Fianlizer类进行区分,下文要提到的finalizer类的地方都说成f类),gc在处理这种类的对象的时候要做一些特殊的处理,如在这个对象被回收之前会调用一下它的finalize方法。

如何判断一个类是不是一个f类

在讲这个问题之前,我们先来看下java.lang.Object里的一个方法

在Object类里定义了一个名为finalize的空方法,这意味着Java世界里的所有类都会继承这个方法,甚至可以覆写该方法,并且根据方法覆写原则,如果子类覆盖此方法,方法访问权限都是至少是protected级别的,这样其子类就算没有覆写此方法也会继承此方法。

而判断当前类是否是一个f类的标准并不仅仅是当前类是否含有一个参数为空,返回值为void的名为finalize的方法,而另外一个要求是finalize方法必须非空,因此我们的Object类虽然含有一个finalize方法,但是并不是一个f类,Object的对象在被gc回收的时候其实并不会去调用它的finalize方法。

需要注意的是我们的类在被加载过程中其实就已经被标记为是否为f类了(遍历所有方法,包括父类的方法,只要有一个非空的参数为空返回void的finalize方法就认为是一个f类)。

f类的对象何时传到Finalizer.register方法

对象的创建其实是被拆分成多个步骤的,比如A a=new A(2)这样一条语句对应的字节码如下:

先执行new分配好对象空间,然后再执行invokespecial调用构造函数,jvm里其实可以让用户选择在这两个时机中的任意一个将当前对象传递给Finalizer.register方法来注册到Finalizer对象链里,这个选择依赖于RegisterFinalizersAtInit这个vm参数是否被设置,默认值为true,也就是在调用构造函数返回之前调用Finalizer.register方法,如果通过-XX:-RegisterFinalizersAtInit关闭了该参数,那将在对象空间分配好之后就将这个对象注册进去。

另外需要提一点的是当我们通过clone的方式复制一个对象的时候,如果当前类是一个f类,那么在clone完成的时候将调用Finalizer.register方法进行注册。

hotspot如何实现f类对象在构造函数执行完毕后调用Finalizer.register

这个实现比较有意思,在这里简单提一下,我们知道一个构造函数执行的时候,会去调用父类的构造函数,主要是为了能对继承自父类的属性也能做初始化,那么任何一个对象的初始化最终都会调用到Object的空构造函数里(任何空的构造函数其实并不空,会含有三条字节码指令,如下代码所示),为了不对所有的类的构造函数都做埋点调用Finalizer.register方法,hotspot的实现是在Object这个类在做初始化的时候将构造函数里的return指令替换为_return_register_finalizer指令,该指令并不是标准的字节码指令,是hotspot扩展的指令,这样在处理该指令的时候调用Finalizer.register方法,这样就在侵入性很小的情况下完美地解决了这个问题。

f类对象的GC回收

FinalizerThread线程

在Finalizer类的clinit方法(静态块)里我们看到它会创建了一个FinalizerThread的守护线程,这个线程的优先级并不是最高的,意味着在cpu很紧张的情况下其被调度的优先级可能会受到影响

这个线程主要就是从queue里取Finalizer对象,然后执行该对象的runFinalizer方法,这个方法主要是将Finalizer对象从Finalizer对象链里剥离出来,这样意味着下次gc发生的时候就可能将其关联的f对象gc掉了,最后将这个Finalizer对象关联的f对象传给了一个native方法invokeFinalizeMethod

其实invokeFinalizeMethod方法就是调了这个f对象的finalize方法,看到这里大家应该恍然大悟了,整个过程都串起来了

Finalizer对象何时被放到ReferenceQueue里

那究竟什么时候会将Finalizer对象丢到ReferenceQueue里呢?当gc发生的时候,gc算法会判断f类对象是不是只被Finalizer类引用(f类对象被Finalizer对象引用,然后放到Finalizer对象链里),如果这个对象仅仅被Finalizer对象引用的时候,说明这个对象在不久的将来会被回收了,可以执行它的finalize方法了,于是会将这个Finalizer对象放到Reference.pending字段里(是一个Reference对象,但是是链式的)。但是这个f类对象其实并没有被回收,因为Finalizer这个类还对他们持有引用,在gc完成之前,jvm会调用java.lang.ref.Reference里的lock对象的notify方法(当Reference.pending为空的时候,有个专门处理引用的叫做ReferenceHandler的线程会一直在wait),ReferenceHandler这个线程在处理的时候会将对应的Finalizer对象丢到Finalizer类的ReferenceQueue里,此时因为ReferenceQueue非空了,于是FinalizerThread会执行上面FinalizeThread线程里看到的其他逻辑了。这个过程可能有点绕,最好是结合代码看看,下面简单绘了一个图

f对象的finalize方法抛出异常会导致FinalizeThread退出吗

不知道大家有没有想过如果f对象的finalize方法抛了一个没捕获的异常,这个FinalizerThread会不会退出呢,细心的读者看上面的代码其实就可以找到答案,在runFinalizer方法里对Throwable的异常都进行了捕获,因此不可能出现FinalizerThread因异常未捕获而退出的情况。

f对象的finalize方法会执行多次吗

如果我们在f对象的finalize方法里重新将当前对象赋值出去,变成可达对象,当这个f对象再次变成不可达的时候还会被执行finalize方法吗?答案是否定的,因为在执行完第一次finalize方法之后,这个f对象已经和之前的Finalizer对象关系剥离了,也就是下次gc的时候不会再发现Finalizer对象指向该f对象了,自然也就不会调用这个f对象的finalize方法了。

Finalizer导致的内存泄漏

这里举一个简单的例子,我们使用挺广的socket通信,SocksSocketImpl的父类其实就实现了finalize方法:

其实这么做的主要目的是万一用户忘记关闭socket了,那么在这个对象被回收的时候能主动关闭socket来释放一些系统资源,但是如果真的是用户忘记关闭了,那这些socket对象可能因为FinalizeThread迟迟没有执行到这些socket对象的finalize方法,而导致内存泄露,这种问题我们碰到过多次,需要特别注意的是对于已经没有地方引用的这些f对象,并不会在最近的那一次gc里马上回收掉,而是会延迟到下一个或者下几个gc时才被回收,因为执行finalize方法的动作无法在gc过程中执行,万一finalize方法执行很长呢,所以只能在这个gc周期里将这个垃圾对象重新标活,直到执行完finalize方法从queue里删除,这样下次gc的时候就真的是漂浮垃圾了会被回收,因此给大家的一个建议是千万不要在运行期不断创建f对象,不然会很悲剧。

Finalizer的客观评价

上面的过程基本对Finalizer的实现细节进行完整剖析了,java里我们看到有构造函数,但是并没有看到析构函数一说,Finalizer其实是实现了析构函数的概念,我们在对象被回收前可以执行一些『收拾性』的逻辑,应该说是一个特殊场景的补充,但是这种概念的实现给我们的f对象生命周期以及gc等带来了一些影响:

  • f对象因为Finalizer的引用而变成了一个临时的强引用,即使没有其他的强引用了,还是无法立即被回收
  • f对象至少经历两次GC才能被回收,因为只有在FinalizerThread执行完了f对象的finalize方法的情况下才有可能被下次gc回收,而有可能期间已经经历过多次gc了,但是一直还没执行f对象的finalize方法
  • cpu资源比较稀缺的情况下FinalizerThread线程有可能因为优先级比较低而延迟执行f对象的finalize方法
  • 因为f对象的finalize方法迟迟没有执行,有可能会导致大部分f对象进入到old分代,此时容易引发old分代的gc,甚至fullgc,gc暂停时间明显变长
  • f对象的finalize方法被调用了,但是这个对象其实还并没有被回收,虽然可能在不久的将来会被回收

推荐到 PerfMa 社区 免费使用 JVM 参数、Java 内存分析、Java 线程分析等产品!

原文地址:JVM源码分析之警惕存在内存泄漏风险的FinalReference(增强版)

阅读推荐:一次 Young GC 的优化实践(FinalReference 相关)

原文地址:https://www.cnblogs.com/perfma/p/12205139.html

时间: 2024-07-28 21:30:10

JVM源码分析之警惕存在内存泄漏风险的FinalReference(增强版)的相关文章

JVM源码分析之堆外内存完全解读

概述 广义的堆外内存 说到堆外内存,那大家肯定想到堆内内存,这也是我们大家接触最多的,我们在jvm参数里通常设置-Xmx来指定我们的堆的最大值,不过这还不是我们理解的Java堆,-Xmx的值是新生代和老生代的和的最大值,我们在jvm参数里通常还会加一个参数-XX:MaxPermSize来指定持久代的最大值,那么我们认识的Java堆的最大值其实是-Xmx和-XX:MaxPermSize的总和,在分代算法下,新生代,老生代和持久代是连续的虚拟地址,因为它们是一起分配的,那么剩下的都可以认为是堆外内存

18.一篇文章,从源码深入详解ThreadLocal内存泄漏问题

1. 造成内存泄漏的原因? threadLocal是为了解决对象不能被多线程共享访问的问题,通过threadLocal.set方法将对象实例保存在每个线程自己所拥有的threadLocalMap中,这样每个线程使用自己的对象实例,彼此不会影响达到隔离的作用,从而就解决了对象在被共享访问带来线程安全问题.如果将同步机制和threadLocal做一个横向比较的话,同步机制就是通过控制线程访问共享对象的顺序,而threadLocal就是为每一个线程分配一个该对象,各用各的互不影响.打个比方说,现在有1

JVM源码分析之SystemGC完全解读

概述 JVM的GC一般情况下是JVM本身根据一定的条件触发的,不过我们还是可以做一些人为的触发,比如通过jvmti做强制GC,通过System.gc触发,还可以通过jmap来触发等,针对每个场景其实我们都可以写篇文章来做一个介绍,本文重点介绍下System.gc的原理 或许大家已经知道如下相关的知识 system.gc其实是做一次full gc system.gc会暂停整个进程 system.gc一般情况下我们要禁掉,使用-XX:+DisableExplicitGC system.gc在cms

JVM源码分析之栈溢出完全解读

概述 之所以想写这篇文章,其实是因为最近有不少系统出现了栈溢出导致进程crash的问题,并且很隐蔽,根本原因还得借助coredump才能分析出来,于是想从JVM实现的角度来全面分析下栈溢出的这类问题,或许你碰到过如下的场景: 日志里出现了StackOverflowError的异常 进程突然消失了,但是留下了crash日志 进程消失了,crash日志也没有留下 这些都可能是栈溢出导致的. 如何定位是否是栈溢出 上面提到的后面两种情况有可能不是我们今天要聊的栈溢出的问题导致的crash,也许是别的一

JVM源码分析之javaagent原理完全解读

概述 本文重点讲述javaagent的具体实现,因为它面向的是我们Java程序员,而且agent都是用Java编写的,不需要太多的C/C++编程基础,不过这篇文章里也会讲到JVMTIAgent(C实现的),因为javaagent的运行还是依赖于一个特殊的JVMTIAgent. 对于javaagent,或许大家都听过,甚至使用过,常见的用法大致如下: java -javaagent:myagent.jar=mode=test Test 我们通过-javaagent来指定我们编写的agent的jar

JVM源码分析之不保证顺序的Class.getMethods

概述 本文要说的内容是今天公司有个线上系统踩了一个坑,并且貌似还造成了一定的影响,后来系统相关的人定位到了是java.lang.Class.getMethods返回的顺序可能不同机器不一样,有问题的机器和没问题的机器这个返回的方法列表是不一样的,后面他们就来找到我求证是否jdk里有这潜规则 本来这个问题简单一句话就可以说明白,所以在晚上推送的消息里也将这个事实告诉了大家,大家知道就好,以后不要再掉到坑里去了,但是这个要细说起来其实也值得一说,于是在消息就附加了征求大家意见的内容,看大家是否有兴趣

Spark源码分析之八:Task运行(二)

在<Spark源码分析之七:Task运行(一)>一文中,我们详细叙述了Task运行的整体流程,最终Task被传输到Executor上,启动一个对应的TaskRunner线程,并且在线程池中被调度执行.继而,我们对TaskRunner的run()方法进行了详细的分析,总结出了其内Task执行的三个主要步骤: Step1:Task及其运行时需要的辅助对象构造,主要包括: 1.当前线程设置上下文类加载器: 2.获取序列化器ser: 3.更新任务状态TaskState: 4.计算垃圾回收时间: 5.反

并发编程(四):ThreadLocal从源码分析总结到内存泄漏

一.目录 1.ThreadLocal是什么?有什么用? 2.ThreadLocal源码简要总结? 3.ThreadLocal为什么会导致内存泄漏? 二.ThreadLocal是什么?有什么用? 引入话题:在并发条件下,如何正确获得共享数据?举例:假设有多个用户需要获取用户信息,一个线程对应一个用户.在mybatis中,session用于操作数据库,那么设置.获取操作分别是session.set().session.get(),如何保证每个线程都能正确操作达到想要的结果? /** * 回顾sync

Netty源码分析--内存模型(上)(十一)

前两节我们分别看了FastThreadLocal和ThreadLocal的源码分析,并且在第八节的时候讲到了处理一个客户端的接入请求,一个客户端是接入进来的,是怎么注册到多路复用器上的.那么这一节我们来一起看下客户端接入完成之后,是怎么实现读写操作的?我们自己想一下,应该就是为刚刚读取的数据分配一块缓冲区,然后把channel中的信息写入到缓冲区中,然后传入到各个handler链上,分别进行处理.那Netty是怎么去分配一块缓冲区的呢?这个就涉及到了Netty的内存模型. 当然,我们在第一节的时