前言

一般生产运行的服务都会有相关的指标监控,方便我们查看服务的健康状态,Grafana+Prometheus是一种常见的方式。本文主要是根据Grafana通过对内存的监控发现内存出现异常(泄漏)进而排查与处理的过程。

修改策略中涉及到通用的内容,后续使用相关技术时需要关注下。


发现问题

Grafana监控:
image-1698208922196

image-1698208927860

由图可以发现:
1、内存回收不彻底,jvm heap趋势递增,出现内存泄漏
2、S区内存使用骤增,可能有大对象存在,进入老年代
3、老年代也存在内存逐步上升,有无法正常回收或大对象,最终引发full gc


分析过程

dump 线上堆内存,dump时间点,在heap上升接近commit时。

dump命令:jmap -dump:format=b,file=./heap.hprof PID

拿到dump后,使用内存快照分析软件进行分析,这里用的JProfiler。
image-1698208985319
找到最大对象,96M,占用44%,对应类:com.mysql.cj.jdbc.AbandonedConnectionCleanupThread
初步确认该类下有引用Map结构,map中存在无法回收的对象。

然后,点击map 右键 “使用选中对象”,如下图所示,选择传出引用,查看map中引用的实例:

如果是传入引用,查看的是被谁所引用。

image-1698209042122

image-1698209053142
可以看到map中实例信息,对应数据库连接等信息,map中近5000个这样的对象。

此时,基本定位到核心类,com.mysql.cj.jdbc.AbandonedConnectionCleanupThread,接下来深入分析下:
该类位于mysql-connector-java:8.0.20包下,类关键描述:

/**
 * This class implements a thread that is responsible for closing abandoned MySQL connections, i.e., connections that are not explicitly closed.
 * There is only one instance of this class and there is a single thread to do this task. This thread's executor is statically referenced in this same class.
 */
public class AbandonedConnectionCleanupThread implements Runnable {
    // 存储数据
    private static final Set<ConnectionFinalizerPhantomReference> connectionFinalizerPhantomRefs = ConcurrentHashMap.newKeySet();
    private static final ReferenceQueue<MysqlConnection> referenceQueue = new ReferenceQueue<>();

    // 辅助属性
    private static final ExecutorService cleanupThreadExcecutorService;
    private static Thread threadRef = null;
    private static Lock threadRefLock = new ReentrantLock();

    /**
     * 线程任务:执行关闭废弃的mysql连接
     */
	public void run() {
        for (;;) {
            try {
                checkThreadContextClassLoader();
                Reference<? extends MysqlConnection> reference = referenceQueue.remove(5000);
                if (reference != null) {
                    finalizeResource((ConnectionFinalizerPhantomReference) reference);
                }
            } catch (InterruptedException e) {
                threadRefLock.lock();
                try {
                    threadRef = null;

                    // Finalize remaining references.
                    Reference<? extends MysqlConnection> reference;
                    while ((reference = referenceQueue.poll()) != null) {
                        finalizeResource((ConnectionFinalizerPhantomReference) reference);
                    }
                    connectionFinalizerPhantomRefs.clear();
                } finally {
                    threadRefLock.unlock();
                }
                return;
            } catch (Exception ex) {
                // Nowhere to really log this.
            }
        }
    }

    /**
     * 创建新的数据库连接会调用,进行引用关联
     */
    protected static void trackConnection(MysqlConnection conn, NetworkResources io) {
        threadRefLock.lock();
        try {
            if (isAlive()) {
                ConnectionFinalizerPhantomReference reference = new ConnectionFinalizerPhantomReference(conn, io, referenceQueue);
                connectionFinalizerPhantomRefs.add(reference);
            }
        } finally {
            threadRefLock.unlock();
        }
    }
    
    /**
     * PhantomReference的子类,追踪mysql连接情况。
     * This class holds a reference to the Connection's {@link NetworkResources} so they it can be later closed.
     */
    private static class ConnectionFinalizerPhantomReference extends PhantomReference<MysqlConnection> {
        private NetworkResources networkResources;

        ConnectionFinalizerPhantomReference(MysqlConnection conn, NetworkResources networkResources, ReferenceQueue<? super MysqlConnection> refQueue) {
            super(conn, refQueue);
            this.networkResources = networkResources;
        }

        void finalizeResources() {
            if (this.networkResources != null) {
                try {
                    this.networkResources.forceClose();
                } finally {
                    this.networkResources = null;
                }
            }
        }
    }
}

该类定义了一个线程负责关闭废弃的mysql连接,该类仅1个实例,启动1个单线程进行处理。

两个核心静态常量:connectionFinalizerPhantomRefs、referenceQueue
connectionFinalizerPhantomRefs:内包括map集合,集合类型ConnectionFinalizerPhantomReference,对应有数据库连接,和heap的内容吻合。

referenceQueue:ReferenceQueue类型,是一个引用队列,ConnectionFinalizerPhantomReference类继承了PhantomReference(虚引用),两者需要结合使用,当虚引用对应的对象被GC回收时能得到通知,把虚引用对象(PhantomReference) 放到Queue队列中,通过获取队列中的元素,确认引用对象要被GC了,做额外的处理。
ConnectionFinalizerPhantomReference构造器的这句 super(conn, refQueue); 完成虚引用的创建,同时绑定引用队列。

两个核心方法:run()、trackConnection()
run(): Runnable接口方法,当前类加载时,开始执行任务,开启了1个线程在死循环执行,通过referenceQueue获取虚引用的连接对象,然后执行释放相关资源的操作,见当前类的finalizeResource方法。

trackConnection():每次数据库连接池在创建新连接后,都会调用该方法,创建ConnectionFinalizerPhantomReference引用对象,将连接和引用对象关联,并加入到connectionFinalizerPhantomRefs map集合中。


确认这些后,接着分析为啥还是有近5W个引用没有释放呢?

先看下HikariPC次的以下四个核心参数:

# 最小空闲连接,默认10个,需小于maximum-pool-size
minimum-idle: 10
# 最大连接,默认10个
maximum-pool-size: 10
# 大于minimum-idle的连接存活最大毫秒数,默认10分钟,不能低于10s
idle-timeout: 600000
# 闲置链接存活时长最长毫秒数,默认30分钟,不能低于30s
max-lifetime: 1800000

HikariPC连接池默认会每30分钟(由max-lifetime参数决定)将当前闲置的连接进行关闭,并重新创建新的连接,保证连接池中不少于minimum-idle个连接。且该时间不会因为连接刚执行过查询而重新计算,只要是定时检查的时候是空闲的。

1、先确认数据库连接的引用
image-1698209291053
通过数据库连接的传入引用来看,引用的入口确实只有AbandonedConnectionCleanupThread。HikariPC中确实close了,没有对其的引用了。

2、AbandonedConnectionCleanupThread的清除任务,逻辑也比较简单,run循环调用gc的连接,进行finalizeResource调用。

private static void finalizeResource(ConnectionFinalizerPhantomReference reference) {
try {
    reference.finalizeResources();
    reference.clear();
} finally {
    connectionFinalizerPhantomRefs.remove(reference);
}
}

void finalizeResources() {
    if (this.networkResources != null) {
        try {
            this.networkResources.forceClose();
        } finally {
            this.networkResources = null;
        }
    }
}

如果connectionFinalizerPhantomRefs.remove方法没被调用 -> 队列没数据 -> 这部分连接还没有被GC

所以综上来看
1、目前看HikariPC创建和关闭连接没有问题、AbandonedConnectionCleanupThread逻辑也没问题。
2、整个内存趋势上升,是存在内存泄漏的;再次结合老年代内存情况,full gc是可以被回收的,如果一直内存泄漏得不到回收,最后一定会OOM。
3、所以是,连接池close掉的数据库连接,在close前多次yong gc后进入了老年代,导致只有full gc才能被回收掉。表象就是AbandonedConnectionCleanupThread导致了内存中数据库连接一直被引用,导致内存无法有效回收。


修改策略

1、修改max-lifetime,将其调至更大值,如6-7小时;小于数据库的连接超时时间一般8个小时(show variables like '%wait_timeout%'查看)。
image-1698209415771

图片来自HikariCP官方

2、禁用AbandonedConnectionCleanupThread,通过升级mysql-connector-java至8.0.22版本,同时jvm参数增加 -Dcom.mysql.cj.disableAbandonedConnectionCleanup=true。

参考:
HikariPC提的issue: https://github.com/brettwooldridge/HikariCP/issues/1473
mysql-connector-java版本更新:https://bugs.mysql.com/bug.php?id=96870


前后对比

24小时

image-1698209533135

image-1698209538114

最终:减少内存的占用和full gc;同时去除了AbandonedConnectionCleanupThread的引用。

监控中S区飙升因为其它大对象,对应慢查询,以及通过逻辑优化处理。


一些验证

环境:
jdk8
mysql8.0.32、
mysql:mysql-connector-java:8.0.20
HikarPC:4.0.3
JProfier12.0.4

验证1、在数据库连接被关闭后,经过yong GC可以触发AbandonedConnectionCleanupThread清除任务。
JVM参数:-Xms2G -Xmx2G -XX:NewRatio=1(年轻代:老年代=1:1)
配置HikarPC数据库连接池核心参数:

hikari:
  minimum-idle: 50
  maximum-pool-size: 50
  idle-timeout: 10000
  # 配置40s,每40s创建50个连接。
  max-lifetime: 40000

启动服务,无流量监控:
image-1698209616047
debug打开,在每次gc时,触发虚引用finalizeResource()。
图中标记位置,通过堆内存分析,connectionFinalizerPhantomRefs约60个对象。

说明:yong gc可以回收被AbandonedConnectionCleanupThread引用的连接。


验证2、在数据库连接被关闭后,进入老年代能够被gc回收。
JVM参数:-Xms256M -Xmx256 -XX:NewRatio=1 -XX:MaxTenuringThreshold=1
MaxTenuringThreshold:yong gc 1次,未回收的内存进入老年代

hikari:
  minimum-idle: 100
  maximum-pool-size: 100
  idle-timeout: 10000
  # 配置5分钟,每5分钟创建100个连接,
  max-lifetime: 300000

每次创建新的数据库连接后,模拟流量触发gc,监控old区:
image-1698209691056

image-1698209696129

yong gc到达老年代,结合验证1和堆内存快照,其中old区包括200个连接对象。

说明:连接进入老年代,yong gc无法回收,后续只能full gc了。

full gc能回收,参考案例


验证3、使用-Dcom.mysql.cj.disableAbandonedConnectionCleanup=true后,会不会导致连接池连接关闭后无法释放等问题。
JVM参数:-Xms256M -Xmx256 -XX:NewRatio=1
-Dcom.mysql.cj.disableAbandonedConnectionCleanup=true
调整版本:mysql:mysql-connector-java:8.0.22
配置HikarPC数据库连接池核心参数:

hikari:
  minimum-idle: 5
  maximum-pool-size: 5
  idle-timeout: 10000
  # 配置40s,每40s创建5个连接,
  max-lifetime: 40000
  • 连接释放确认

从mysql角度看,连接是否能正常关闭。
先看mysql数据库当前连接数,初始化状态,执行:show processlist;
image-1698209806977

启动服务后,再次执行show processlist:
image-1698209814340

40s后,HikarPC关闭之前的连接,创建新一批连接,再次执行show processlist:
image-1698209821893

从host的port可以看出不是同一批连接了。

  • 资源释放确认
    AbandonedConnectionCleanupThread 在清除任务中,会执行如下方法清除networkResources的资源。
void finalizeResources() {
      if (this.networkResources != null) {
          try {
            	// com.mysql.cj.protocol.NetworkResources#forceClose
              this.networkResources.forceClose();
          } finally {
              this.networkResources = null;
          }
      }
  }

禁用AbandonedConnectionCleanupThread,该方法com.mysql.cj.protocol.NetworkResources#forceClose在close连接时仍能被正常调用。

说明:修改策略2无副作用。


总结

问题的出现主要是HikariCP连接池的maxLifetime参数,每半个小时就会对闲置的连接进行重新创建连接并close原连接,这个过程连接已经进入老年代了,只能靠full gc,再加上服务本身的特性,属于多数据源的配置,加重了这一情况。

通过修改maxLifetime接近默认数据库连接释放时间8小时进行减缓内存的消耗。

数据库的连接超时时间在有查询后会被重置的,可以通过show processlist测试。

思考:
1、maxLifetime参数释放也可以同idleTimeout一样在有查询请求后进行重置呢。
2、maxLifetime设置成0使不主动重建,如果数据库连接层面超时,连接池在查询时被动重建连接,会有请求上的波动。