Java常见编程错误:线程池

Posted liekkas01

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java常见编程错误:线程池相关的知识,希望对你有一定的参考价值。

由于线程的创建⽐较昂贵,随意、没有控制地创建⼤量线程会造成性能问题,因此短平快的任务⼀般考虑使 ⽤线程池来处理,⽽不是直接创建线程。

通过三个⽣产事故,来看看使⽤线程池应该注意些什么。

 

线程池的声明需要⼿动进⾏

Java中的Executors类定义了⼀些快捷的⼯具⽅法,来帮助我们快速创建线程池。《阿⾥巴巴Java开发⼿ 册》中提到,禁⽌使⽤这些⽅法来创建线程池,⽽应该⼿动new ThreadPoolExecutor来创建线程池。

最典型的就是newFixedThreadPool和 newCachedThreadPool,可能因为资源耗尽导致OOM问题。

场景描述

写⼀段测试代码,来初始化⼀个单线程的FixedThreadPool,循环1亿次向线程池提交任务,每个任务都会创建⼀个⽐较⼤的字符串然后休眠⼀⼩时:

执⾏程序后不久,⽇志中就出现OOM

public void oom1() throws InterruptedException {
        ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
        for (int i = 0; i < 100000000; i++) {
            threadPool.execute(() -> {
                String payload = IntStream.rangeClosed(1, 1000000)
                        .mapToObj(__ -> "a")
                        .collect(Collectors.joining("")) + UUID.randomUUID().toString();
                try {
                    TimeUnit.HOURS.sleep(1);
                } catch (InterruptedException e) {
                }

            });
        }
        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.HOURS);
    }

 

看newFixedThreadPool⽅法的源码发现,线程池的⼯作队列直接new了⼀个 LinkedBlockingQueue,⽽默认构造⽅法的LinkedBlockingQueue是⼀个Integer.MAX_VALUE⻓度的队列,可以认为是⽆界的:

public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>());
    }
    public class LinkedBlockingQueue<E> extends AbstractQueue<E>
            implements BlockingQueue<E>, java.io.Serializable {
        ...
        /**
         * Creates a {@code LinkedBlockingQueue} with a capacity of
         * {@link Integer#MAX_VALUE}.
         */
        public LinkedBlockingQueue() {
            this(Integer.MAX_VALUE);
        }
        ...
    }    

 

虽然使⽤newFixedThreadPool可以把⼯作线程控制在固定的数量上,但任务队列是⽆界的。如果任务较多 并且执⾏较慢的话,队列可能会快速积压,撑爆内存导致OOM。

把刚才的例⼦稍微改⼀下,改为使⽤newCachedThreadPool⽅法来获得线程池。程序运⾏不久后, 同样看到了OOM异常

这次OOM的原因是⽆法创建线程,翻看newCachedThreadPool的源码可以看到,这种线程池的最⼤线程数是Integer.MAX_VALUE,可以认为是没有上限的,⽽其⼯作队列 SynchronousQueue是⼀个没有存储空间的阻塞队列。这意味着,只要有请求到来,就必须找到⼀条⼯作线程来处理,如果当前没有空闲的线程就再创建⼀条新的。

由于任务需要1⼩时才能执⾏完成,⼤量的任务进来后会创建⼤量的线程。线程是需要分配⼀定的内存空间作为线程栈的,⽐如1MB,因此⽆限制创建线程必然会导致OOM

public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                60L, TimeUnit.SECONDS,
                new SynchronousQueue<Runnable>());
}

事故描述:

抱有侥幸⼼理,觉得只是使⽤线程池做⼀些轻量级的任务,不可能造成队列积压或开启⼤量线程。

⽤⼾注册后,调⽤⼀个外部服务去发送短信, 发送短信接⼝正常时可以在100毫秒内响应,TPS 100的注册量,CachedThreadPool能稳定在占⽤10个左 右线程的情况下满⾜需求。在某个时间点,外部短信服务不可⽤了,我们调⽤这个服务的超时⼜特别⻓, ⽐如1分钟,1分钟可能就进来了6000⽤⼾,产⽣6000个发送短信的任务,需要6000个线程,没多久就因为⽆法创建线程导致了OOM,整个应⽤程序崩溃。

 

不建议使⽤Executors提供的两种快捷的线程池,原因如下:

  • 我们需要根据⾃⼰的场景、并发情况来评估线程池的⼏个核⼼参数,包括核⼼线程数、最⼤线程数、线程 回收策略、⼯作队列的类型,以及拒绝策略,确保线程池的⼯作⾏为符合需求,⼀般都需要设置有界的⼯ 作队列和可控的线程数。
  • 任何时候,都应该为⾃定义线程池指定有意义的名称,以⽅便排查问题。当出现线程数量暴增、线程死锁、线程占⽤⼤量CPU、线程执⾏出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以⽅便我们定位问题。

线程池线程管理策略详解

除了⼿动声明线程池以外,还可以⽤⼀些监控⼿段来观察线程池的状态。线程池这个组件除⾮是出现了拒绝策略,否则压⼒再⼤都不会抛出⼀个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。

程池线程管理策略详解

⽤⼀个printStats⽅法实现了最简陋的监控,每秒输出⼀次线程池的基本内部信息,包括线程数、活跃线程数、完成了多少任务,以及队列中还有多少积压任务等信息:

private void printStats(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            log.info("=========================");
            log.info("Pool Size: {}", threadPool.getPoolSize());
            log.info("Active Threads: {}", threadPool.getActiveCount());
            log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
            log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
            log.info("=========================");
        }, 0, 1, TimeUnit.SECONDS);
    }

验证方案

⾃定义⼀个线程池。这个线程池具有2个核⼼线程、5个最⼤线程、使⽤容量为10的ArrayBlockingQueue阻塞队列作为⼯作队列,使⽤默认的AbortPolicy拒绝策略,也就是任务添加到线程池失败会抛出RejectedExecutionException。此外,借助Jodd类库的ThreadFactoryBuilder⽅法来构造⼀个线程⼯⼚,实现线程池线程的⾃定义命名。

写⼀段测试代码来观察线程池管理线程的策略。测试代码的逻辑为,每次间隔1秒向线程池提交 任务,循环20次,每个任务需要10秒才能执⾏完成,代码如下:

public static int right() throws InterruptedException {
        //  使用一个计数器跟踪完成的任务数
        AtomicInteger atomicInteger = new AtomicInteger();

        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
                2, 5,
                5, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(10),
                new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").build(),
                new ThreadPoolExecutor.AbortPolicy()
        );

        printStatus(threadPool);
        //每隔1s提交一次任务,一共提交20次

        IntStream.rangeClosed(1, 20).forEach(
                i -> {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    int id = atomicInteger.incrementAndGet();
                    try {
                        threadPool.submit(() -> {
                                    System.out.println(id + " started.");

                                    try {
                                        TimeUnit.SECONDS.sleep(10);
                                    } catch (InterruptedException e) {

                                    }

                                    System.out.println(id + " finished.");
                                }
                        );
                    } catch (Exception ex) {
                        //提交出现异常的话,打印出错信息并为计数器减⼀
                        System.out.println("error submitting task " + id + " " + ex.toString());
                        atomicInteger.decrementAndGet();
                    }
                }
        );

        TimeUnit.SECONDS.sleep(60);
        return atomicInteger.intValue();


    }

    private static void printStatus(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
                () -> {
                    System.out.println("=========================");
                    System.out.println("Pool Size: " + threadPool.getPoolSize());
                    System.out.println("Active Threads: " + threadPool.getActiveCount());
                    System.out.println("Number of Tasks Completed: " + threadPool.getCompletedTaskCount());
                    System.out.println(("Number of Tasks in Queue: " + threadPool.getQueue().size()));
                    System.out.println("=========================");
                }, 0, 1, TimeUnit.SECONDS
        );
    }

60秒后⻚⾯输出了17,有3次提交失败了,把printStats⽅法打印出的⽇志绘制成图表,得出如下曲线:

 

可以总结出线程池默认的⼯作⾏为:

  • 不会初始化corePoolSize个线程,有任务来了才创建⼯作线程;
  • 当核⼼线程满了之后不会⽴即扩容线程池,⽽是把任务堆积到⼯作队列中;
  • 当⼯作队列满了后扩容线程池,⼀直到线程个数达到maximumPoolSize为⽌;
  • 如果队列已满且达到了最⼤线程后还有任务进来,按照拒绝策略处理;
  • 当线程数⼤于核⼼线程数时,线程等待keepAliveTime后还是没有任务需要处理的话,收缩线程到核⼼线程数。 

也可以 通过⼀些⼿段来改变线程池的默认⼯作⾏为:

  • 声明线程池后⽴即调⽤prestartAllCoreThreads⽅法,来启动所有核⼼线程;
  • 传⼊true给allowCoreThreadTimeOut⽅法,来让线程池在空闲的时候同样回收核⼼线程。

务必确认清楚线程池本⾝是不是复⽤的

 

以上是关于Java常见编程错误:线程池的主要内容,如果未能解决你的问题,请参考以下文章

Java线程池详解

Java线程池详解

Java 线程池详解

Java线程池详解

Day625.线程池常见错误 -Java业务开发常见错误

Java 并发编程 常见面试总结