线程池原因导致java.lang.OutOfMemoryError

线程池原因导致java.lang.OutOfMemoryError

July 19, 2021

问题描述

线上环境某个服务经常性地抛出内存溢出,看日志是下面的错误

java.lang.OutOfMemoryError: unable to create new native thread
    at java.lang.Thread.start0(Native Method) ~[?:1.8.0_112]
    at java.lang.Thread.start(Thread.java:714) ~[?:1.8.0_112]
    at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:950) ~[?:1.8.0_112]
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1357) ~[?:1.8.0_112]
    at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:134) ~[?:1.8.0_112]

造成这个错误主要有2个原因:

  1. 剩余的系统内存不足,导致无法创建新的线程
  2. 总线程数达到操作系统允许的上限

看了代码后,发现开发人员把线程池作为一个方法的局部变量,由于这方法是被定时任务调用的,也就意味着线程池局部变量会被实例化N次,如果线程池没有被回收,那么最终总线程数会达到操作系统的上限。

问题剖析

当对象实例不再使用或者方法执行完毕后,什么时候会释放线程与关闭线程池?不同的线程池的实现方式可能不一样,但是主要还是看是否设置了核心线程数。

  • 如果没有设置核心线程数,比如 newCachedThreadPool ,在线程池的线程空闲时间到达 60s 后,线程会关闭,所有线程关闭后线程池也相应关闭回收。
  • 如果设置了核心线程数,比如 newSingleThreadExecutor 和 newFixedThreadPool,如果没有主动去关闭,或者设置核心线程的超时时间,核心线程会一直存在不会被关闭,这个线程池就不会被释放回收。

可以通过下面的ThreadPoolExecutor源码中runWorker方法,看到要执行线程退出processWorkerExit需要这几种情况:

  1. 线程池的状态 >= STOP。对于这个情况,线程池的状态要达到 STOP,需要调用shutdown或者shutdownNow方法
  2. getTask 获取到空任务
final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // If pool is stopping, ensure thread is interrupted;
            // if not, ensure thread is not interrupted.  This
            // requires a recheck in second case to deal with
            // shutdownNow race while clearing interrupt
            if ((runStateAtLeast(ctl.get(), STOP) ||
                  (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task);
                Throwable thrown = null;
                try {
                    task.run();
                } catch (RuntimeException x) {
                    thrown = x; throw x;
                } catch (Error x) {
                    thrown = x; throw x;
                } catch (Throwable x) {
                    thrown = x; throw new Error(x);
                } finally {
                    afterExecute(task, thrown);
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

对于第二种情况,先看看getTask方法的源码。

private Runnable getTask() {
    boolean timedOut = false; // Did the last poll() time out?

    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        // Check if queue empty only if necessary.
        if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
            decrementWorkerCount();
            return null;
        }

        int wc = workerCountOf(c);

        // Are workers subject to culling?
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }

        try {
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                workQueue.take();
            if (r != null)
                return r;
            timedOut = true;
        } catch (InterruptedException retry) {
            timedOut = false;
        }
    }
}

从方法中可以看到:

  • 当前线程数大于核心线程,会调用poll超时后返回空任务。
  • 当前线程数小于等于核心线程,并且调用了allowCoreThreadTimeOut方法允许核心线程超时关闭的情况下,也是调用poll,超时后返回空任务。
  • 其他情况,调用take阻塞等待。

任务队列以阻塞队列BlockingQueue为例,该队列提供了两种方法来获取任务:

  • poll,可以设置超时时间,当超时后会得到一个空任务。
  • take,阻塞住,直到有任务出现。

在没有任务的情况下,核心线程正处于getTask,调用阻塞队列BlockingQueuetake方法阻塞等待获取到任务,从而导致线程池包括里面的核心线程迟迟不被关闭并且回收。

小结

不推荐将线程池作为局部变量使用,而要作为全局变量。一般都会把线程池作为类的静态成员或者单例成员,毕竟生命周期和进程一致。

如果业务场景非要这样用的话,并且线程池有核心线程的情况下,要注意做两件事情防止对象泄漏:

  • 对核心线程设置超时时间。
  • 主动调用 shutdown 或 shutdownNow 来关闭线程池。

示例:

public class TestThread {

    public static void main(String[] args) {
        while (true) {
            ExecutorService service = Executors.newFixedThreadPool(1);
            try {
                service.submit(new Runnable() {
                    public void run() {
                        try {
                            Thread.sleep(2000);
                        } catch (InterruptedException e) {
                        }
                    }
                });
            } catch (Exception e) {
            }finally{
                // 调用shutdown来关闭
                service.shutdown();
            }
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
            }
        }
    }
}

参考:

最后更新于