再说J.U.C之线程池(二)

简介: [TOC] ###前言 在上一篇[再说J.U.C之线程池(一)](https://www.atatech.org/articles/97394 "再说J.U.C之线程池(一)")中,我们已经从源码角度分析了线程池在提交任务以及执行任务的整个过程,那我们已经熟悉了这个过程之后,接下来就是要在实际的使用中,避免去踩一些坑,那我们就从几个实际当中用到的几个case来看下线程池在实际使用中需要注意

[TOC]

前言

在上一篇再说J.U.C之线程池(一)")中,我们已经从源码角度分析了线程池在提交任务以及执行任务的整个过程,那我们已经熟悉了这个过程之后,接下来就是要在实际的使用中,避免去踩一些坑,那我们就从几个实际当中用到的几个case来看下线程池在实际使用中需要注意的问题。

实战总结

命名

我们在创建线程池的时候,一定要给线程池名字,类似如下的写法

 public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 10; i++) {
            final int finalI = i;
            executorService.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + ":" + finalI);
                }
            });
        }
    }

最后的输出:

pool-1-thread-3:2
pool-1-thread-2:1
pool-1-thread-3:4
pool-1-thread-1:3
pool-1-thread-3:6
pool-1-thread-2:5
pool-1-thread-3:8
pool-1-thread-1:7
pool-1-thread-2:9

Executors中有个默认的线程工厂的实现:

static class DefaultThreadFactory implements ThreadFactory {
        private static final AtomicInteger poolNumber = new AtomicInteger(1);
        private final ThreadGroup group;
        private final AtomicInteger threadNumber = new AtomicInteger(1);
        private final String namePrefix;

        DefaultThreadFactory() {
            SecurityManager s = System.getSecurityManager();
            group = (s != null) ? s.getThreadGroup() :
                                  Thread.currentThread().getThreadGroup();
            namePrefix = "pool-" +
                          poolNumber.getAndIncrement() +
                         "-thread-";
        }

        public Thread newThread(Runnable r) {
            Thread t = new Thread(group, r,
                                  namePrefix + threadNumber.getAndIncrement(),
                                  0);
            if (t.isDaemon())
                t.setDaemon(false);
            if (t.getPriority() != Thread.NORM_PRIORITY)
                t.setPriority(Thread.NORM_PRIORITY);
            return t;
        }
    }

我们可以通过改造一下

 public class NamedThreadFactory implements ThreadFactory {
    private final AtomicInteger threadNumber;
    private final String name;
    private final boolean isDaemon;

    public NamedThreadFactory(String name) {
        this(name, false);
    }

    public NamedThreadFactory(String name, boolean daemon) {
        this.threadNumber = new AtomicInteger(1);
        this.isDaemon = daemon;
        this.name = name + "-thread-pool-";
    }

    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, this.name + this.threadNumber.getAndIncrement());
        t.setDaemon(this.isDaemon);
        if (t.getPriority() != Thread.NORM_PRIORITY){
            t.setPriority(Thread.NORM_PRIORITY);
        }
        return t;
    }
}

那我们看下改造之后的输出结果:

有名字的线程池-thread-pool-1:0
有名字的线程池-thread-pool-3:2
有名字的线程池-thread-pool-1:3
有名字的线程池-thread-pool-2:1
有名字的线程池-thread-pool-1:5
有名字的线程池-thread-pool-1:7
有名字的线程池-thread-pool-1:8
有名字的线程池-thread-pool-3:4
有名字的线程池-thread-pool-1:9
有名字的线程池-thread-pool-2:6

这样的话,当我们应用线上出现问题,需要通过jstack查看线程堆栈的时候,就可以知道是哪些线程出现的问题,不然我们看到的都是统一的命名方式,这样等真正出问题的时候,看到都是清一色的线程,这。。。只能增加排查问题的难度

线程池的关闭

我们先看一个简单的例子:

这里写图片描述

这个其实和jvm的关闭有关系,我们看下jvm的退出的几种情况:

这里写图片描述

我们自定义的线程池如果都是非守护线程在运行,那只要线程池没有结束,jvm是无法退出的,跟我们刚才的这个例子是一样的,非守护线程没有结束,jvm无法退出。

那我们想象一下这样的场景,我们的应用在重启的时候,先要关闭应用进程,这个时候虽然应用已经不接受外部的请求了,但是由于线程池没有关闭,导致jvm无法退出,那发布系统在一直等待jvm关闭等待超时,会使用kill -9命令直接关闭进程,这个时候强制关闭线程池,那可能会导致我们线程池的队列中有部分的任务没有处理完,直接结束了,这样会出现处于队列中的请求没有返回出现问题。
我们的web项目都是通过spring来进行管理的,那只要随着bean的销毁,我们关闭线程池就可以了:

public class MyThreadBean implements DisposableBean{
    ExecutorService executorService = Executors.newFixedThreadPool(3, new NamedThreadFactory("有名字的线程池"));
    @Override
    public void destroy() throws Exception {
        executorService.shutdown();
    }
}

我们可以保证在我们的webapplicationContext关闭的时候,销毁spring bean,同时在bean的销毁的时候,调用线程池的shutDown()方法,保证其完成内部队列的任务再进行关闭。
当然spring自己也提供了 ThreadPoolTaskExecutor ,可以用spring的这个来定义线程池,它的destroy方法:


    /**
     * Calls <code>shutdown</code> when the BeanFactory destroys
     * the task executor instance.
     * @see #shutdown()
     */
    public void destroy() {
        shutdown();
    }

    /**
     * Perform a shutdown on the ThreadPoolExecutor.
     * @see java.util.concurrent.ExecutorService#shutdown()
     */
    public void shutdown() {
        if (logger.isInfoEnabled()) {
            logger.info("Shutting down ExecutorService" + (this.beanName != null ? " '" + this.beanName + "'" : ""));
        }
        if (this.waitForTasksToCompleteOnShutdown) {
            this.executor.shutdown();
        }
        else {
            this.executor.shutdownNow();
        }
    }

异常处理

子线程吞并异常

我们先看一段代码:

public class MyThreadBean{
    private final static Logger logger = LoggerFactory.getLogger(MyThreadBean.class);
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2,3,45,
        TimeUnit.SECONDS,new ArrayBlockingQueue<Runnable>(3),new NamedThreadFactory("有名字的线程池"));

    public void execute1(){
        for (int i = 0; i < 10; i++) {
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.SECONDS.sleep(2);
                    } catch (InterruptedException e) {
                       logger.error(e.getMessage(), e);
                    }
                    logger.info(Thread.currentThread().getName() + "执行结束");
                }
            });
        }
    }

    public void execute2(){
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    threadPoolExecutor.execute(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                TimeUnit.SECONDS.sleep(2);
                            } catch (InterruptedException e) {
                                logger.error(e.getMessage(), e);
                        }
                            logger.info(Thread.currentThread().getName() + "执行结束");
                        }
                    });
                }
            }
        }).start();

    }
}

看下分别执行execute1()和execute2()这两个方法,会出现什么样的结果

execute1()的输出结果:
这里写图片描述
execute2()的输出结果:
这里写图片描述

对比一下这两种执行的结果,execute1在执行的过程中会抛异常,而在execute2执行的过程中会吞掉异常,不会抛出,当然应用也就不会报警了。这个是因为execute2在执行的过程中,线程池是单独起了一个线程去执行的,而我们的报警日志或者切面打印日志都是在主线程中执行的,在子线程中并没有这样的处理,所以子线程是不会打印这个异常的,那就有个疑问,这个异常最终会打印到哪里呢?

java线程的异常处理

Runnable的run方法的定义也是要求不抛出checked异常

public interface Runnable {
    public abstract void run();
}

那如果run方法中抛出了RuntimeException要如何去处理?
在Thread中通常设置了一个默认的处理方式

  public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(
                new RuntimePermission("setDefaultUncaughtExceptionHandler")
                    );
        }

         defaultUncaughtExceptionHandler = eh;
     }

那我们可以覆盖次方法并实现UncaughtExceptionHandler来定义我们自己的处理异常的逻辑:

   public interface UncaughtExceptionHandler {
        void uncaughtException(Thread t, Throwable e);
    }

那我们看下jdk默认的异常处理把异常打印到了哪里:

 public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            parent.uncaughtException(t, e);
        } else {
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                //我们看到这里会是我们经常在main方法写出的异常信息打印的内容,有点熟悉
                System.err.print("Exception in thread \""
                                 + t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }

简单可以写个demo:

  public static void main(String[] args) {
        Thread.currentThread().setUncaughtExceptionHandler(new UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println("异常就在这里了");
            }
        });

        int a = 1/0;
        System.out.println("haha");
    }

最后的输出:

异常就在这里了

Process finished with exit code 1

了解了这个我们大概知道异常通过System.err.print输出了,所以我们的日志不会输出任何异常,这个可能就会导致我们在排查问题的时候发现线程的处理没有出现异常,但是就是没有把任务处理完,结果总是不符合预期,就是因为在子线程中没有异常的输出位置。
我们再回过头看下execute2()这个方法,这里的异常有两部分:

  1. execute2提交任务的时候,出现reject异常,这个时候由于这个线程是我们自己创建的,所以我们可以捕获这个异常来处理,也就是在execute2()方法中的线程里加try..catch来捕获这个异常,这样就能解决这个问题。即:
 public void execute2(){
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    try {
                        threadPoolExecutor.execute(new Runnable() {
                            @Override
                            public void run() {
                                try {
                                    TimeUnit.SECONDS.sleep(2);
                                } catch (InterruptedException e) {
                                    logger.error(e.getMessage(), e);
                                }
                                logger.info(Thread.currentThread().getName() + "执行结束");
                            }
                        });
                    }catch (Exception e){
                      logger.info("提交任务出现异常", e);  
                    }
                    
                }
            }
        }).start();

    }

execute方法执行的过程中只会抛RejectExecuteException,所以我们如果不想抛异常的话,也可以复写RejectedExecutionHandler来处理,比如这样:

public class SyncInfoRejectHandler implements RejectedExecutionHandler {
    final Logger logger = LoggerFactory.getLogger(getClass());
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        BlockingQueue<Runnable> queue = executor.getQueue();
        try {
        //把任务重新放到队列中,不过这个更多的是定时任务处理数据,保证任务不被拒绝可以这么去处理
            queue.put(r);
            logger.info("SyncInfoRejectHandler.put a runnable to executor success");
        } catch (InterruptedException e) {
            logger.error("SyncInfoRejectHandler.rejectedExecution interrupt", e);
        }

    }

}
  1. 另一个异常是本身任务在执行的过程中抛出的异常,我们知道这个异常是worker线程抛出的,并非我们自己写的线程,所以我们无法控制,可以有一种方案类似我们上面提到的实现UncaughtExceptionHandler来实现,在线程池的线程工厂里为线程设置统一的异常处理器。不过我们经常做的应该只是对我们提交的这个任务的run方法里加try...catch之后,再打印出来异常,这个也可以算是一种处理方式,不过这就要求了我们一定要在提交的任务里加try...catch来打印异常,不然异常不会打印到日志中,相当于被吞了。

submit异常吞并

我们平时都是通过submit来提交一个Callable,那如果提交的是Runnable呢,为方便起见我们核心的代码都放在一起了

 public Future<?> submit(Runnable task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<Void> ftask = newTaskFor(task, null);
        execute(ftask);
        return ftask;
    }
    
 protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
        return new FutureTask<T>(runnable, value);
    }

 public FutureTask(Runnable runnable, V result) {
        this.callable = Executors.callable(runnable, result);
        this.state = NEW;       // ensure visibility of callable
    }
 public static <T> Callable<T> callable(Runnable task, T result) {
        if (task == null)
            throw new NullPointerException();
        return new RunnableAdapter<T>(task, result);
    }
    //最终FutureTask中的callable指向的是一个RunnableAdapter,而RunnableAdapter的call方法也是调用了我们传进来的task的run方法,返回的是null
  static final class RunnableAdapter<T> implements Callable<T> {
        final Runnable task;
        final T result;
        RunnableAdapter(Runnable task, T result) {
            this.task = task;
            this.result = result;
        }
        public T call() {
            task.run();
            return result;
        }
    }

那从这里我们就知道,我们通过submit传递进去的Runnale,最后在FutureTask的run方法里面调用的callable.call()实质上还是我们传递进去的runnable的run方法,而上一篇我们在分析FutureTask的run方法的时候有专门强调过,FutureTask中执行我们的任务如果出现异常,是不会抛出来的,必须通过get方法才可以获取到,当然也可以重写afterExecute()这个回调方法,在这个里面来调用get获取异常信息,还是要重点强调下,我们在通过submit执行任务的时候,一定要调用get()方法
我这里重写afterExecute()方法,来获取submit(Runnable task)的执行异常demo:

 protected void afterExecute(Runnable r, Throwable t) {
            super.afterExecute(r, t);
            //执行的Callable,对应的t一定是Null
            if (t == null && r instanceof Future) {
                try {
                    Future future = (Future) r;
                    if (future.isDone()){
                        // 判断任务是否执行完成
                        future.get();
                    }
                } catch (CancellationException ce) {
                    t = ce;
                } catch (ExecutionException ee) {
                    t = ee.getCause();
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt(); 
                }
            }
        }

队列的选择

这里我们只说下ArrayBlockingQueue和LinkedBlockingQueue,后续我们会对这两个队列详细的讲。
ArrayBlockingQueue


    /** Main lock guarding all access */
    final ReentrantLock lock;

    /** Condition for waiting takes */
    private final Condition notEmpty;

    /** Condition for waiting puts */
    private final Condition notFull;

我们可以看到ArrayBlockingQueue只有一个主锁,通过这个主锁分别获取控制put的notFull以及获取控制take的notEmpty两个condition,那这个就决定了要往队列里面添加或者获取元素,都必须要竞争这一个锁,如果并发线程比较多的话,可能会导致竞争会比较激烈,性能会下降。

LinkedBlockingQueue

 /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();

与ArrayBlockingQueue不同,LinkedBlockingQueue有两个锁,分别来控制获取元素和往队列里面放元素,即生产者和消费者线程不会发生竞争,在队列里有元素的条件下。以此来看,在并发比较多的情况下LinkedBlockingQueue的性能会好点,不过由于ArrayBlockingQueue是数组,一开始就可以分配好大小,后续内存不会发生大的变化,而LinkedBlockingQueue是链表,在频繁的入队和出队的情况下,内存波动会比较大,具体视业务以及数据量而定来选择。

线程数设置

这里网上有大量的篇幅在提到,可以推荐看下这篇文章:线程数究竟设置多少合理 这里我简单说下个人经验吧

  1. 结合当前任务的类型属于cpu密集型还是io密集型,具体哪种类型设置多少,这篇文章也都有计算的公式。不过这里提下,如果是cpu密集型的也可以考虑fork/join.
  2. 一定需要感知每个任务处理花费的时间,并且结合当前任务可以承受的qps是多少,通过时间以及qps来看队列应该设置多大,一定得保证在队列满的时候,最后一个任务的处理在超时可允许的时间范围内,不然如果队列里的其他任务处理没问题,等到处理最后一些任务完了,客户端都超时了,那说明队列设置太大了。

CountDownLatch 丢失事件

我们在处理一批任务的时候,往往会把任务进行partition,然后再交给每个线程去处理,那主线程需要等待所有的线程处理完,来统计本次处理的时间,以及其他统计的数据,差不多和下面这段代码类似:

public void execute3(){
        List<Integer> data = new ArrayList<Integer>(100);
        for (int i = 0; i < 100; i++) {
            data.add(i + 10);
        }

        List<List<Integer>> partition = Lists.partition(data, 20);
        final CountDownLatch countDownLatch = new CountDownLatch(partition.size());
        for (final List<Integer> dataToHandle : partition) {
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    try{
                        for (Integer i : dataToHandle) {
                            doSomeThing(i);
                        }
                    }catch (Exception e){
                       logger.error(e.getMessage(), e);
                    }finally {  
                        countDownLatch.countDown();
                    }
                }
            });
        }

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            logger.error(e.getMessage(), e);
        }

        logger.info("任务执行结束...");
    }

之前这么写代码没有出现过问题,直到最近出现问题才发现这么写会导致主线程无法结束的问题。我们看下,虽然在每个任务的finally中进行处理

countDownLatch.countDown();

但是有一点忽视了,我们在上面异常那块其实有提到过,如果线程池满了,抛出RejectExecuteException的话,那这次任务的countDownLatch就会被忽视,当然我们这是在主线程里执行,直接会抛出异常导致主线程结束,但是如果和上面提到的在单独的子线程里面去执行这个线程池,那这样的话由于主线程无法捕获到子线程的异常,就会出现主线程无法结束的问题,所以我们在子线程中执行线程池一定要避免这点 即如果在子线程中执行,需要改为下面这样:

 public void execute3(){
        List<Integer> data = new ArrayList<Integer>(100);
        for (int i = 0; i < 100; i++) {
            data.add(i + 10);
        }

        final List<List<Integer>> partition = Lists.partition(data, 20);
        final CountDownLatch countDownLatch = new CountDownLatch(partition.size());
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (final List<Integer> dataToHandle : partition) {
                    try {
                        threadPoolExecutor.execute(new Runnable() {
                            @Override
                            public void run() {
                                try{
                                    for (Integer i : dataToHandle) {
                                        doSomeThing(i);
                                    }
                                }catch (Exception e){
                                    logger.error(e.getMessage(), e);
                                }finally {
    
                                    countDownLatch.countDown();
                                }
                            }
                        });
                    } catch (RejectedExecutionException e) {
                        logger.error(e.getMessage(), e);
                        //处理完异常之后需要补充countDownLatch事件
                        countDownLatch.countDown();
                    }
                }

            }
        }).start();
       
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            logger.error(e.getMessage(), e);
        }

        logger.info("任务执行结束...");
    }

自此,我们线程池这一块就讲完了,我们在分析线程池的源码的时候看到很多在修改线程池的状态或者workerCount的时候都是用的UnSafe来操作的,这个就引出了CAS,我们下一篇来讲下并发中的CAS。


参考
https://tech.imdada.cn/2017/06/18/jvm-safe-exit/

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
25天前
|
存储 Java 调度
浅谈线程池
浅谈线程池
15 1
|
6月前
|
Java
6. 实现简单的线程池
6. 实现简单的线程池
27 0
|
9月前
|
监控 Java
线程池的讲解和实现
线程池的讲解和实现
|
10月前
|
缓存 Java 调度
线程池的介绍
线程池的介绍
|
10月前
|
前端开发 Java 调度
|
10月前
|
前端开发 Java 调度
你了解线程池吗
你了解线程池吗
53 0
|
11月前
|
存储 Java 测试技术
13.一文彻底了解线程池
大家好,我是王有志。线程池是Java面试中必问的八股文,涉及到非常多的问题,今天我们就通过一篇文章,来彻底搞懂Java面试中关于线程池的问题。
369 2
13.一文彻底了解线程池
|
11月前
|
缓存 算法 Java
线程池和使用
线程池是一种用于管理和复用线程的机制。在多线程应用程序中,线程的创建和销毁需要消耗大量的系统资源,而线程池可以通过预先创建一定数量的线程,然后将任务分配给这些线程来避免频繁地创建和销毁线程,从而提高应用程序的性能和效率。线程池还可以控制并发线程的数量,避免过多的线程竞争资源导致的性能下降和系统崩溃。线程池是多线程编程中常用的一种技术,被广泛应用于各种类型的应用程序中。
54 0
线程池和使用
|
12月前
|
Java
KeyAffinityExecutor 线程池
KeyAffinityExecutor 线程池
|
Java 调度
线程池 的一些事
线程池 的一些事
99 0
线程池 的一些事