基于Netty连接池泄露问题了解客户端启动源码

连接池导致内存泄漏案例演示

简介

我们生产环境常常会用Netty客户端作为连接工具,尽管Netty强大且方便,但是使用不当的话也可能造成严重的生成事故。笔者本文就以一个连接池使用不当导致内存泄漏的案例来展开探讨。

问题复现

服务端代码

我们先贴出服务端代码,代码非常简单,就是启动,然后处理客户端请求。

public class Server {

	public static void main(String[] args) {
		NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
		NioEventLoopGroup workerGroup = new NioEventLoopGroup();

		try {
			ServerBootstrap bootstrap = new ServerBootstrap();
			bootstrap.group(bossGroup, workerGroup)
					.channel(NioServerSocketChannel.class)
					.option(ChannelOption.SO_BACKLOG, 100)
					.handler(new LoggingHandler())
					.childHandler(new ChannelInitializer<SocketChannel>() {
						@Override
						protected void initChannel(SocketChannel ch) throws Exception {
							ChannelPipeline pipeline = ch.pipeline();
							pipeline.addLast(new LoggingHandler());
						}
					});
			ChannelFuture channelFuture = bootstrap.bind(9999).sync();
			//channelFuture.channel().closeFuture().sync();
			channelFuture.channel().closeFuture().addListener(new ChannelFutureListener() {
				@Override
				public void operationComplete(ChannelFuture future) throws Exception {
					System.out.println(">>>>>>>>>>>>>>>链路关闭!");
					bossGroup.shutdownGracefully();
					workerGroup.shutdownGracefully();
				}
			});
			/*TimeUnit.SECONDS.sleep(4);
			channelFuture.channel().close();*/
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			/*bossGroup.shutdownGracefully();
			workerGroup.shutdownGracefully();*/
		}
	}
}

服务端代码

服务端代码如下,这里笔者用poolSize来模拟客户端并发请求,通过传入的poolSize创建poolSize个客户端和服务端建立连接。

public class Client {

	static void initPool(int poolSize) {
        
        for (int i = 0; i < poolSize; i++) {
            NioEventLoopGroup bossGroup = new NioEventLoopGroup();

            try {
                Bootstrap bootstrap = new Bootstrap();
                bootstrap.group(bossGroup)
                        .channel(NioSocketChannel.class)
                        .option(ChannelOption.TCP_NODELAY, true)
                        //.handler(new LoggingHandler())
                        .handler(new ChannelInitializer<SocketChannel>() {
                            @Override
                            protected void initChannel(SocketChannel ch) throws Exception {
                                ChannelPipeline pipeline = ch.pipeline();
                            }
                        });

                ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9999).sync();
                channelFuture.channel().closeFuture().addListener(new ChannelFutureListener() {
                    @Override
                    public void operationComplete(ChannelFuture future) throws Exception {
                        System.out.println(">>>>>>>>>>>>>>>链路关闭!");
                        //bossGroup.shutdownGracefully();
                        channelFuture.channel().close();
                    }
                });

            } catch (Exception e) {
                e.printStackTrace();
            }
        }


	}

	
}

完成上述编码之后,我们编写启动代码,可以看到笔者这里启动了200个并发请求。

public static void main(String[] args) {

		try {
			TimeUnit.SECONDS.sleep(20);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
			initPool(200);
		System.out.println(">>>>>>>>>>>>连接池创建成功!");
	}

启动并重现问题

完成编码工作之后,我们先把服务端启动,然后为了更快的重现问题,我们在启动客户端之前,需对客户端配置如下参数调整堆区

-Xmn32m -Xmx32m

启动后不久,我们就发现客户端并发请求服务端时,抛出内存泄漏的错误

Exception in thread "nioEventLoopGroup-23-1" Exception in thread "nioEventLoopGroup-28-1" Exception in thread "nioEventLoopGroup-28-16" java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "RMI TCP Connection(idle)" Exception in thread "nioEventLoopGroup-28-8" java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at io.netty.channel.nio.SelectedSelectionKeySet.<init>(SelectedSelectionKeySet.java:29)
	at io.netty.channel.nio.NioEventLoop.openSelector(NioEventLoop.java:207)
	at io.netty.channel.nio.NioEventLoop.<init>(NioEventLoop.java:149)
	at io.netty.channel.nio.NioEventLoopGroup.newChild(NioEventLoopGroup.java:127)
	at io.netty.channel.nio.NioEventLoopGroup.newChild(NioEventLoopGroup.java:36)
	at io.netty.util.concurrent.MultithreadEventExecutorGroup.<init>(MultithreadEventExecutorGroup.java:84)
	at io.netty.util.concurrent.MultithreadEventExecutorGroup.<init>(MultithreadEventExecutorGroup.java:58)
	at io.netty.channel.MultithreadEventLoopGroup.<init>(MultithreadEventLoopGroup.java:52)
	at io.netty.channel.nio.NioEventLoopGroup.<init>(NioEventLoopGroup.java:87)
	at io.netty.channel.nio.NioEventLoopGroup.<init>(NioEventLoopGroup.java:82)
	at io.netty.channel.nio.NioEventLoopGroup.<init>(NioEventLoopGroup.java:63)
	at io.netty.channel.nio.NioEventLoopGroup.<init>(NioEventLoopGroup.java:51)
	at io.netty.channel.nio.NioEventLoopGroup.<init>(NioEventLoopGroup.java:43)
	at com.mx.tuning.case2.Client.initPool(Client.java:29)
	at com.mx.tuning.case2.Client.main(Client.java:70)
Exception in thread "nioEventLoopGroup-26-1" java.lang.OutOfMemoryError: GC overhead limit exceeded

连接池泄漏原因详解

我们打开jvisualvm查看客户端线程信息,可以看到客户端创建无数个独立的NioEventLoopGroup。

基于Netty连接池泄露问题了解客户端启动源码_第1张图片

查看server却发现,都是使用同一个NioEventLoopGroup,每个请求都是通过NioEventLoopGroup中的一个线程去处理的。

基于Netty连接池泄露问题了解客户端启动源码_第2张图片

很明显造成客户端连接池泄漏的原因,就是我们错用的线程池,我们每个客户端发起请求时用的都是各自的NioEventLoopGroup,这不仅使得连接池没有复用,更使得nio模型用起来和bio没有区别。

基于Netty连接池泄露问题了解客户端启动源码_第3张图片

对此,我们不妨看看NioEventLoopGroup的源码,先从构造方法入手,可以看到默认情况下会调用一个 this(0);

 public NioEventLoopGroup() {
        this(0);
    }

经过我们的不断步入,即可发现默认情况下EventLoopGroup会创建DEFAULT_EVENT_LOOP_THREADS 个线程

protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
        super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
    }

我们不妨通过源码看看DEFAULT_EVENT_LOOP_THREADS 的线程数。如下图,默认情况下,线程池数是CPU核心数的2倍。

基于Netty连接池泄露问题了解客户端启动源码_第4张图片

以笔者为例,笔者的CPU核心数为16,那么最终结果则是32。

基于Netty连接池泄露问题了解客户端启动源码_第5张图片

这一点,我们完全可以将Netty源码的结果输出打印出来

System.out.println("thread num="+Math.max(1, SystemPropertyUtil.getInt(
				"io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2)));

可以看到结果确实是32

在这里插入图片描述

完成线程数初始化的工作之后,源码就会为ThreadPerTaskExecutor初始化对应个数的执行器处理后续的各种异步任务。

基于Netty连接池泄露问题了解客户端启动源码_第6张图片

解决方案

可以想到一共200个请求都会创建32个线程,在32m的内存空间是多么可怕的一件事,所以我们不妨对客户端做一个调整,使得每一个请求都可以复用一个NioEventLoopGroup。

改造后的代码如下所示,可以看到笔者将循环建立到连接操作上,这样一来就确保的所有的客户端请求都复用一个NioEventLoopGroup。

基于Netty连接池泄露问题了解客户端启动源码_第7张图片

再次启动我们就发现客户端所有连接都成功了,使用监控工具查看线程池也没有问题,线程数确实是CPU核心数的2倍。

基于Netty连接池泄露问题了解客户端启动源码_第8张图片

控制台也输出连接成功了。

基于Netty连接池泄露问题了解客户端启动源码_第9张图片

补充注意事项

这里我们补充一个注意事项,可以看到笔者对每一个请求结束后的关闭方法并不是将bossGroup关闭,而是将每个客户端对应的管道即channelFuture.channel()关闭。这样一来,我们也确保了一个连接报错之后,不会将其他连接对应的NioEventLoopGroup关闭了。

基于Netty连接池泄露问题了解客户端启动源码_第10张图片

更进一步:客户端连接源码详解

Netty客户端创建原理

图解

了解了客户端连接池的错误使用案例之后,我们不妨对客户端创建进行进一步的了解,先来看看下面这张经典的时序图,可以看到Netty客户端建立连接的方式大抵是以下几个步骤:

  1. 创建客户端
  2. 构建NIO线程组,这也就是我们说的创建NioEventLoopGroup
  3. 反射创建频道
  4. 创建频道流水线管理要处理的管道
  5. 上述初始化完成之后,通过异步的方式发起TCP连接
  6. 然后异步处理连接
  7. 连接操作成功后发起连接操作结果事件
  8. 调用我们编写的业务代码的handler

基于Netty连接池泄露问题了解客户端启动源码_第11张图片

源码验证

这里我们不妨对几个比较核心的步骤通过源码的方式进行一下分析,创建NioEventLoopGroup这一步我们就不多说了,上文已经说明了,感兴趣的读者可以自行进一步查看源码。

然后就是反射创建频道,这一步在源码中的这个位置,可以看到这个代码我们会将管道的类传入

基于Netty连接池泄露问题了解客户端启动源码_第12张图片

步入源码,我们也能发现这块代码会通过一个反射工厂完成频道的创建。

基于Netty连接池泄露问题了解客户端启动源码_第13张图片

后续频道流水线初始化这里不是重点,我们这里简单查看一下即可

基于Netty连接池泄露问题了解客户端启动源码_第14张图片

服务端流水线操作也是同理,通过addLast方法来编排频道的排序。

基于Netty连接池泄露问题了解客户端启动源码_第15张图片

自此我们将客户端创建的流程整体有了整体的了解,下面我们不妨进一步了解一下客户端连接的工作流程。

Netyy客户端注册源码详解

上文我们大概的过了初始化配置的源码,接下来我们就来了解一下bootstrap连接服务器的原理。我们首先将服务端代码启动。

然后调整一下客户端启动代码,再将其启动

 public static void main(String[] args) {


        initPool(1);
        System.out.println(">>>>>>>>>>>>连接池创建成功!");
    }

然后我们在connect处插入断点,并将客户端代码启动

基于Netty连接池泄露问题了解客户端启动源码_第16张图片

此时,不断步入,我们的代码走到了Bootstrap的connect方法,启动有一个doResolveAndConnect,我们不妨步入查看详情。

 public ChannelFuture connect(SocketAddress remoteAddress) {
      ......
        return doResolveAndConnect(remoteAddress, config.localAddress());
    }

然后代码会走到一个initAndRegister方法,该方法完成之后会生成一个ChannelFuture 的异步任务,任务发起后,后续代码会注册一个监听器,监听注册结果以及根据注册结果发起真正的远程连接代码,我们先不妨看看异步任务生成的逻辑方法initAndRegister为我们做了些什么。

 private ChannelFuture doResolveAndConnect(final SocketAddress remoteAddress, final SocketAddress localAddress) {
        final ChannelFuture regFuture = initAndRegister();
....

if (regFuture.isDone()) {
          ..........
        } else {
        ......
        //上述异步注册任务发起后,设置一个监听器,一旦上述注册任务完成就会执行监听器中的doResolveAndConnect0方法发起连接
            regFuture.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) throws Exception {
                    // Directly obtain the cause and do a null check so we only need one volatile read in case of a
                    // failure.
                    Throwable cause = future.cause();
                    if (cause != null) {
                     ......
                    } else {
                     //发起客户端连接服务端doResolveAndConnect0
                        promise.registered();
                        doResolveAndConnect0(channel, remoteAddress, localAddress, promise);
                    }
                }
            });
            return promise;
        }

}

可以看到该方法内部为调用一个register方法,我们不妨看看这个register做了些什么。

 final ChannelFuture initAndRegister() {
        Channel channel = null;
      .....

        ChannelFuture regFuture = config().group().register(channel);
      ......
        }

我们不断这深入这段代码,可以看到AbstractChannel中的register会通过eventLoop提交一个register0的任务,我们不妨看看eventLoop的execute方法做了些什么。

 @Override
        public final void register(EventLoop eventLoop, final ChannelPromise promise) {
           

            AbstractChannel.this.eventLoop = eventLoop;

            if (eventLoop.inEventLoop()) {
                .....
            } else {
                try {
                    eventLoop.execute(new Runnable() {
                        @Override
                        public void run() {
                            register0(promise);
                        }
                    });
                } catch (Throwable t) {
                   ......
                }
            }
        }

可以看到execute方法,执行了两段核心代码:

  1. addTask,即将我们register0这个任务提交到队列中
  2. 调用startThread启动NioEventLoop获取并运行队列中的任务。

所以我们不妨看看startThread是如何启动线程去执行我们的register这个任务的。

@Override
    public void execute(Runnable task) {
      ....
      //添加任务到队列中
        addTask(task);
        if (!inEventLoop) {
        // 如果NioEventLoop没启动,则启动让其跑任务
            startThread();
            if (isShutdown() && removeTask(task)) {
                reject();
            }
        }

      ......
    }

可以看到startThread方法调用了doStartThread去启动线程,我们不妨看看它做了些什么。

private void startThread() {
        if (state == ST_NOT_STARTED) {
            if (STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
                try {
                    doStartThread();
                } catch (Throwable cause) {
                   ......
                }
            }
        }
    }

由于代码比较长,笔者这里便使用图片的形式来展示一下代码,可以看到这段代码中会通过threadPerTaskExecutor提交一个任务,该任务便是获取任务中的线程然后,调用 SingleThreadEventExecutor.this.run();方法,读者如果查看一下this对象即可发现,这个this就是我们的NioEventLoop,我们不妨看看run方法内部做了些什么。

基于Netty连接池泄露问题了解客户端启动源码_第17张图片

步入源码后,我们会发现该run方法是一个无限的for循环,会获取本次通到事件的key

基于Netty连接池泄露问题了解客户端启动源码_第18张图片

因为我们是初次建立连接,所以代码走到了这里,内部没有做任务事情,所以笔者直接略过这段代码。然后就走到了下图的runAllTasks

基于Netty连接池泄露问题了解客户端启动源码_第19张图片

最终代码就会从队列中取出我们的register0的任务,并执行。

基于Netty连接池泄露问题了解客户端启动源码_第20张图片

由此走向了一个闭环。

基于Netty连接池泄露问题了解客户端启动源码_第21张图片

自此,我们在这里做一个小结,整个任务的流程:

  1. 调用connect方法。
  2. 调用initAndRegister封装并提交一个register0的异步任务。
  3. 添加上述任务到队列中。
  4. 启动NioEventLoop线程去处理提交到队列中的任务。
  5. NioEventLoop获取通到事件,得到上述的register0。
  6. 执行register0。

Netty注册源码核心异步执行流程

上文中我们代码最终走到了register0,我们不妨看看register0做了些什么。经过笔者的整理,可以看到该方法大抵做了3个步骤:

  1. 调用doRegister
  2. 流水线注册新的handler即调用invokeHandlerAddedIfNeeded
  3. 对promise发起连接注册成功后的通知,即调用safeSetSuccess方法。

所以我们不妨看看doRegister做了些什么

private void register0(ChannelPromise promise) {
            try {
        .......
                doRegister();
.......
                // user may already fire events through the pipeline in the ChannelFutureListener.
                pipeline.invokeHandlerAddedIfNeeded();

                safeSetSuccess(promise);
                pipeline.fireChannelRegistered();
             .........
                }
            } catch (Throwable t) {
               ......
            }
        }

可以看到doRegister会调用register,最终生成一个通到事件的key,赋值给selectionKey ,我们不妨看看生成key之前register方法做了些什么?

@Override
    protected void doRegister() throws Exception {
        boolean selected = false;
        for (;;) {
            try {
                selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this);
                return;
            } catch (CancelledKeyException e) {
               ........
            }
        }
    }

然后我们就来到了register方法,因为是第一次注册,所以k的值为null,代码走到register执行事件注册一个连接事件,然后返回这个key。

public final SelectionKey register(Selector sel, int ops,
                                       Object att)
        throws ClosedChannelException
    {
        synchronized (regLock) {
           .......
            SelectionKey k = findKey(sel);
            if (k != null) {
              .....
            }
            if (k == null) {
              .....
                synchronized (keyLock) {
                    if (!isOpen())
                        throw new ClosedChannelException();
                    k = ((AbstractSelector)sel).register(this, ops, att);
                    addKey(k);
                }
            }
            return k;
        }
    }

自此我们的doRegister即注册都完成了,继续进行执行后续步骤,通知客户端可以真正开始连接了。

基于Netty连接池泄露问题了解客户端启动源码_第22张图片

可以看到,代码走到了promise的trySuccess方法,我们不妨步入看看这个方法为我们做了些什么

protected final void safeSetSuccess(ChannelPromise promise) {
            if (!(promise instanceof VoidChannelPromise) && !promise.trySuccess()) {
                logger.warn("Failed to mark a promise as success because it is done already: {}", promise);
            }
        }

可以看到该段代码会调用一个名为notifyListeners区通知上文注册register监听器。

public boolean trySuccess(V result) {
        if (setSuccess0(result)) {
            notifyListeners();
            return true;
        }
        return false;
    }

查看该通知方法,我们可以看到调用了notifyListener0方法。

基于Netty连接池泄露问题了解客户端启动源码_第23张图片

然后代码就走到了DefaultPromise的notifyListener0方法,它将会调用operationComplete方法,他将会将注册状态设置为true,并让客户端执行连接方法,我们不妨步入看看

private static void notifyListener0(Future future, GenericFutureListener l) {
        try {
            l.operationComplete(future);
        } catch (Throwable t) {
          .....
            }
        }
    }

可以看到,代码最终走到了我们一开始所说的哪个异步任务的后续注册监听的内部逻辑代码中,Bootstrap的doResolveAndConnect ,它做了以下两件事:

  1. 将注册结果设置为true,即调用 promise.registered();
  2. 执行连接逻辑doResolveAndConnect0,我们不妨看看内部执行细节。

基于Netty连接池泄露问题了解客户端启动源码_第24张图片

可以看到其内部核心步骤就是调用doConnect方法。

基于Netty连接池泄露问题了解客户端启动源码_第25张图片

同理,它也是向eventLoopGroup提交一个连接的异步任务,如下图,这里的execute仍然会执行我们上述步骤中的addTask,然后NioEventLoop轮询通到事件的过程。

基于Netty连接池泄露问题了解客户端启动源码_第26张图片

之后代码就执行到上图的channel.connect(remoteAddress, connectPromise);

基于Netty连接池泄露问题了解客户端启动源码_第27张图片

然后这个连接方法就会执行doConnect将OP_CONNECT注册到通到事件中。

 @Override
    protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
        if (localAddress != null) {
            doBind0(localAddress);
        }

        boolean success = false;
        try {
            boolean connected = SocketUtils.connect(javaChannel(), remoteAddress);
            if (!connected) {
                selectionKey().interestOps(SelectionKey.OP_CONNECT);
            }
            success = true;
            return connected;
        } finally {
            if (!success) {
                doClose();
            }
        }
    }

最终NioEventLoop轮询到上述所说的注册的感兴趣的连接事件,完成finishConnect工作,自此所有连接工作完成。

基于Netty连接池泄露问题了解客户端启动源码_第28张图片

小结

自此我们的整个客户端连接工作就完成了,我们不妨小结整个流程:

基于Netty连接池泄露问题了解客户端启动源码_第29张图片

参考

Java性能调优 6步实现项目性能升级

你可能感兴趣的:(常用框架,netty)