最近写了一个学习的小项目,使用到了socket编程,却发现了在不停启动时内存飙升至100%,且永不下降的问题,本文记录下排查的过程。

项目内容简介

MiniCat模拟Tomcat的请求处理内容,是简易版本的Tomcat。这里简化ServerSocket的相关代码如下:

serverSocket.accept()之后均开启线程池,创建新的线程RequestProcessor类来处理该后续流程

public static void main(String[] args) {
    // 定义一个线程池,监听端口和处理请求均公用一个线程池
    ThreadPoolExecutor threadPoolExecutor = getThreadPoolExecutor();

    // 启动每一个HOST项目
    for (Host host : mapper.getAllHost()) {
        threadPoolExecutor.submit(() -> {
            try {
                startOneHost(threadPoolExecutor, host);
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        });
    }
}

private void startOneHost(final ThreadPoolExecutor threadPoolExecutor, final Host host) throws IOException {
    ServerSocket serverSocket = new ServerSocket(host.getPort());
    // 添加钩子程序,正确关闭serverSocket
    Runtime.getRuntime().addShutdownHook(new StopHook(serverSocket));
    System.out.println("=====>>>Minicat start on port:" + host.getPort());

    /*
         * 多线程改造(使用线程池)
         */
    while (true) {
        Socket socket = serverSocket.accept();
        RequestProcessor requestProcessor = new RequestProcessor(socket, host);
        threadPoolExecutor.execute(requestProcessor);
    }
}

private ThreadPoolExecutor getThreadPoolExecutor() {
    int corePoolSize = 10;
    int maximumPoolSize = 50;
    long keepAliveTime = 100L;
    TimeUnit unit = TimeUnit.SECONDS;
    BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(50);
    ThreadFactory threadFactory = Executors.defaultThreadFactory();
    RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();

    return new ThreadPoolExecutor(
        corePoolSize,
        maximumPoolSize,
        keepAliveTime,
        unit,
        workQueue,
        threadFactory,
        handler
    );
}

在RequestProcessor类中将会使用socket.getInputStream().available来判断当前请求是否已经准备好,获取需要读取的字节数。

public class RequestProcessor extends Thread {

    private Socket socket;
    private Map<String, HttpServlet> allServlets;
    private Host host;

    public RequestProcessor(Socket socket, Host host) {
        this.socket = socket;
        this.host = host;
    }

    @Override
    public void run() {
        try {
            InputStream inputStream = socket.getInputStream();
            // 从输入流中获取请求信息
            int count = 0;
            while (count == 0) {
                count = inputStream.available();
            }
            byte[] bytes = new byte[count];
        	inputStream.read(bytes);
            // TODO 省略其他代码
        }catch(Exception ex){
            // ...........
        }finally{
            socket.close();
        }
    }
}

问题发现过程

  1. 启动后利用浏览器Ctrl+R的方式疯狂HTTP请求监听端口下的页面

image-20201227185626849

  1. 内存持续飙升至70%,如果持续请求能飙升至100%,切一直持续不再下降。本文为演示效果,不直接打到100%,否则系统慢慢陷入假死状态了。

image-20201227192447684

问题解决过程

  1. windows平台下使用ProcessExplorer工具查看内存占用线程(软件下载地址:https://docs.microsoft.com/zh-cn/sysinternals/downloads/process-explorer)。

image-20201227192750245

选中java.exe鼠标邮件查看properties

image-20201227193125556

可以看到线程ID号未6252、8352、6528的3个线程分别CPU消耗为24%以上,合起来刚好为CPU消耗的70%多;证明了为该Java项目导致的CPU飙升且常驻;

一般来说,CPU持续占比且不下降,几乎都是线程持续占用,或者死循环引起的。

  1. 使用jstack工具查看线程占用情况
  • 使用JPS命令查看java进程PID

image-20201227194237946

  • 使用 jstack -l PID > f:/xxxx.stack (小写的L) 来生成线程堆栈信息

image-20201227194558702

出现Unable to attach to 64-bit process

7508: Unable to attach to 64-bit process
The -F option can be used when the target process is not responding

因为当前环境的JVM版本与Java程序的JVM版本不一致,如果当前系统里有多个JDK或者JRE版本的话,就会出现此问题。笔者本机是共存了JDK8与JDK11,环境变量设置的8,而MiniCatStater程序启动用的11,直接cd到JDK11的bin目录下继续执行jstack命令

image-20201227195031875

  • 用文本编辑器打开 7508.stack 文件

    上面咱们提到 6252、8352、6528 这三个线程号,选择6252转换为十六进制 0x186C 进行搜索

image-20201227195447348

可以看到线程当前执行位置为 at com.kevin.minicat.server.RequestProcessor.run(RequestProcessor.java:33)

  1. 定位代码位置

image-20201227195923484

可以看到,执行位置为循环体内 count = inputStream.available() 方法,百度一下~

因为网络通讯往往是间断性的,一串字节往往分几批进行发送。例如对方发来字节长度100的数据,本地程序调用available()方法有时得到0,有时得到50,有时能得到100,大多数情况下是100。这可能是对方还没有响应,也可能是对方已经响应了,但是数据还没有送达本地。也许分3批到达,也许分两批,也许一次性到达。

所以为了避免获取数据错误,需要持续调用,等待输入流完全准备完成后再完成对流的读取。

  1. 修复方案

    • 加一个超时时间,如果等待超时,则直接关闭流。
    • 使用Nio方式进行编程。效率更高,不会出问题。BIO阻塞容易异常。

总结

Java的网络编程深似海,只是从其他地方COPY过来的代码,不一定就是最优的,还得自己多思考和多测试。