java线程(五)之线程池

线程池的作用

  1. 线程池作用就是限制系统中执行线程的数量。
  2. 根据系统的环境情况,可以自动或手动设置线程数量,达到运行的最佳效果;少了浪费了系统资源,多了会造成系统拥挤。用线程池控制线程数量,其他线程排队等候。一个任务执行完毕,再从队列的中取最前面的任务开始执行。若队列中没有等待进程,线程池的这一资源处于等待。当一个新任务需要运行时,如果线程池中有等待的工作线程,就可以开始运行了;否则进入等待队列。
  3. 合理使用线程池带来的好处
    • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
    • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就立即执行
    • 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
  4. 为什么要用线程池
    • 减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
    • 可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。
  5. java中的线程池接口: Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService

java线程池的原理

  1. 线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。

  2. 线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。

  3. 线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

  4. ThreadPoolExecutor执行execute()方法示意图

  5. ThreadPoolExecutor执行execute方法分下面4种情况。

    • 如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)
    • 如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue
    • 如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。
    • 如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用
      RejectedExecutionHandler.rejectedExecution()方法。
    • ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。

合理设置线程池

  1. 分析任务的特性

    • 任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
    • 任务的优先级:高、中和低。
    • 任务的执行时间:长、中和短。
    • 任务的依赖性:是否依赖其他系统资源,如数据库连接。
  2. 性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。可以通过Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。

  3. 如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。

  4. 建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。有一次,我们系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞,任务积压在线程池里。如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然,我们的系统所有的任务是用单独的服务器部署的,我们使用不同规模的线程池完成不同类型的任务,但是出现这样问题时也会影响到其他任务。

  5. 确定线程数

    • 如果所有的任务都是计算密集型的,则创建处理器可用核心数个线程就可以。

    • 如果一个任务执行IO操作,其线程将被阻塞,于是处理器可以立即进行上下文切换以便处理其他就绪线程

    • 如果任务有50%的时间处于阻塞状态,则程序所需线程数为处理器可用核心数的两倍

    • 如果任务被阻塞的时间少于50%,即这些任务是计算密集型的,则程序所需线程数将随之减少,但最少也不应低于处理器的核心数。如果任务被阻塞的时间大于执行时间,即该任务是IO密集型,此时就需要创建比处理器核心数大几倍的线程

    • 线程数=CPU可用核心数/(1-阻塞系数),其中阻塞系数的取值在0和1之间。计算密集型任务的阻塞系数为0,IO密集型任务的阻塞系数则接近于1

    • 由于web服务器的请求大部分时间都花在等待服务器响应上,所以阻塞系数相当高,因此程序需要开的线程数可能是处理器核心数的若干倍。假设阻塞系数是0.9,即每个任务90%的时间处于阻塞状态,而只有10%的时间在处理响应。则在双核处理器上我们就需要开启20个线程。

线程池监控

  1. 监控方法
    • taskCount:线程池需要执行的任务数量。
    • completedTaskCount:线程池在运行过程中已完成的任务数量,小于或等于taskCount
    • largestPoolSize:线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是否曾经满过。如该数值等于线程池的最大大小,则表示线程池曾经满过。
    • getPoolSize:线程池的线程数量。如果线程池不销毁的话,线程池里的线程不会自动销毁,所以这个大小只增不减。
    • getActiveCount:获取活动的线程数。
  2. 通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的beforeExecuteafterExecuteterminated方法,也可以在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。这几个方法在线程池里是空方法。

线程池实现类

  1. 重要的类

    ExecutorService线程池接口
    ScheduledExecutorService能和Timer/TimerTask类似,解决那些需要任务重复执行的问题
    ThreadPoolExecutorExecutorService的默认实现
    ScheduledThreadPoolExecutor继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现。
  2. 在Executors类里面提供了一些静态工厂,生成一些常用的线程池

    • newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
    • newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变(剩下的任务将被阻塞直到执行器有空闲的线程可用),如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
    • newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。仅当线程的数量是合理的或者线程只会运行很短的时间时,适合创建可缓存的线程池。
    • newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求,开发定时任务程序时推荐使用此线程池
  3. 每个ExecutorService代表一个线程池,其作用是将线程的创建与执行过程分离开,而不是将线程的生命周期和任务的执行过程绑定在一起。如果不需要线程池,我们可以调用shutdown()来关掉线程池,该方法不会立即将线程销毁,而是会等所有当前已经被调度的任务进行结束之后才会将其关闭,并且在调用该方法之后我们就不能调用任何新任务了。

  4. ThreadPoolExecutor通过submit()发送一个callable对象给执行器去执行,这个submit方法接收callable作为参数,并返回Future对象。Future对象可以用于以下两个目的:

    • 控制任务的状态:可以取消任务和检查任务是否已经完成isDone()
    • 通过call()获取返回结果。可以使用get()方法,这个方法一直等待直到Callable对象的call()方法执行完成并返回结果。如果get()方法在等待结果时线程中断,则将抛出一个InterruptExceptin异常.如果call()方法抛出异常那么get()方法将随之ExetutionException异常
  5. 调用Future对象的get()方法时候,如果Future对象所控制的任务并未完成,那么这个方法将一直阻塞到任务完成。如果想取消一个已经发送给任务执行器的任务,可以使用Future接口的cancel()方法

线程池优化与原则

  1. 线程池的关键点是:

    • 尽量减少线程切换和管理的开支:要求线程数尽量少,这样可以减少线程切换和管理的开支;
    • 最大化利用cpu:要求尽量多的线程,以保证CPU资源最大化的利用。
  2. 任务耗时

    • 对于任务耗时短的情况,要求线程尽量少,如果线程太多,有可能出现线程切换和管理的时间,大于任务执行的时间,那效率就低了;

    • 对于耗时长的任务,要分是cpu任务,还是io等类型的任务。如果是cpu类型的任务,线程数不宜太多;但是如果是io类型的任务,线程多一些更好,可以更充分利用cpu。

  3. 并发度

    • 高并发,低耗时的情况:建议少线程,只要满足并发即可;例如并发100,线程池可能设置为10就可以-
    • 低并发,高耗时的情况:建议多线程,保证有空闲线程,接受新的任务;例如并发10,线程池可能就要设置为20;
    • 高并发高耗时:1要分析任务类型,2增加排队,3、加大线程数
  4. 上下文切换

    • 定义:指CPU的控制权由运行任务转移到另外一个就绪任务时所发生的时间;
    • 高并发,低耗时的情况,上下文切换 本来就多,并且高并发就意味着CPU是处于繁忙状态的, 增加更多地线程也不会让线程得到执行时间片, 反而会增加线程切换的开销.
    • 低并发,高耗时,保证有空闲线程,接收新任务,可以减少线程切换。

技巧

为线程池和线程取名

  1. 当我们在调试程序将JVM中线程导出Dump时,会出现pool-N-thread-M这样的提示,这是缺省的线程池名称,其中N代表池的序列号,每次你创建一个新的线程池,这个N数字就增加1;而M是这个线程池中的线程顺序。举例, pool-2-thread-3 表示在第二个线程池中的第三个线程,JDK将这种命名策略封装在ThreadFactory,Guava可以帮助你方便命名:

    final ThreadFactory threadFactory = new ThreadFactoryBuilder()
            .setNameFormat("monitor-%d")
            .setDaemon(true)
            .build();
    final ExecutorService executorService = Executors.newFixedThreadPool(10, threadFactory);
    
    

根据上下文切换线程名称

  1. 一旦我们记住线程的名称,我们就可以在运行时改变它们,这是有意义的,因为线程dump只显示类和方法名称,没有参数和本地变量。通过调整线程名称可以保留一些比较关键的上下文信息,以便我们方便跟踪具体的消息、记录、查询或者找出引起死锁的原因,如下:

    private void process(String messageId) {
        executorService.submit(() -> {
            final Thread currentThread = Thread.currentThread();
            final String oldName = currentThread.getName();
            currentThread.setName("Processing-" + messageId);
            try {
                //real logic here...
            } finally {
                currentThread.setName(oldName);
            }
        });
    }try-finally中线程被取名为Processing-消息ID,这样我们就能跟踪是哪个消息流经系统。
    

显示安全的关闭线程

  1. 显式地安全地关闭线程: 当程序要关闭时,需要注意任务队列中任务的情况如何以及正在运行的任务执行的情况如何。如果要舍弃任务则执行shutdownNow(),如果要让所有任务都执行完毕,则执行shutdown()

    private void sendAllEmails(List<String> emails) throws InterruptedException {  
        emails.forEach(email ->  
                executorService.submit(() ->  
                        sendEmail(email)));  
        executorService.shutdown();  
        //等待一分钟直到任务执行完成,1min后还是有任务没执行到,则返回false,剩下的任务依然会执行完
        final boolean done = executorService.awaitTermination(1, TimeUnit.MINUTES);  
        log.debug("{}", done);  
    }  
    

处理中断

  1. 当线程堵塞在某个带有InterruptedException的方法时,你可以在这个线程中调用Thread.interrupt(),大多数堵塞的方法将立即抛出InterruptedException.如果你将任务提交给线程池(ExecutorService.submit()),当任务已经在执行时,你能调用Future.cancel(true),在这种情况下线程池将试图中断线程运行你的任务,你能很有效率的中断你的任务。

监控队列和保持长度有限

  1. 不正确大小的线程池可能会导致缓慢、不稳定和内存泄漏。 如果您配置线程太少,将建立队列消耗大量的内存。 另一方面,太多的线程由于过度上下文切换会减慢整个系统,导致相同的症状。 查看队列深度很重要,保持它有界,以便超负荷的线程池可以暂时拒绝新任务

    
    final BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(100);
    executorService = new ThreadPoolExecutor(n, n,
            0L, TimeUnit.MILLISECONDS,
            queue);
    
    
  2. 一个固定100大小的ArrayBlockingQueue。也就是说如果已经有100个任务在队列中了(还有N个在执行中),新的任务就会被拒绝掉,并抛出RejectedExecutionException异常。由于这里的队列是在外部声明的,我们还可以时不时地调用下它的size()方法来将队列大小记录在到日志/JMX/或者你所使用的监控系统中。

异常处理

  1. 以下代码的输出结果是什么?

    executorService.submit(() -> {
     	System.out.println(1 / 0);
    });
    
  2. 它不会打印任何东西。也没有抛出java.lang.ArithmeticException: / by zero的错误,线程池自己吞进了这个Exception,好像从来没有发生一样,这是线程池与普通线程的区别,如果你自己创建一个Thread并提交一个Runnable,你必须自己使用try-catch环抱方法体(或者使用UncaughtExceptionHandler),至少记录日志,如果你提交Callable确保你总是能解除引用,使用堵塞get()到re-throw exception:

    final Future<Integer> division = executorService.submit(() -> 1 / 0);
    //下面将抛出由除零ArithmeticException引起的ExecutionException 
    division.get();
    
  3. Spring框架的@Async曾经也有这个bug

    @Test
    public void threadException(){
        ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(2);
        Future<String> future = newFixedThreadPool.submit(new Callable<String>() {
    
            @Override
            public String call() throws Exception {
                System.out.println(1/0);
                System.out.println("执行........");
                return "打印结果";
            }
        });
        /***
         * 如果仅仅只是执行上面,不会抛异常
         */
        
        /*try {
           String print =  future.get();
           logger.debug(print);
        } catch (InterruptedException | ExecutionException e) {
            logger.error(e.getMessage(),e);
            e.printStackTrace();
        }finally{
            newFixedThreadPool.shutdown();
        }*/
    }
    

监控队列中等待时间

  1. 监控工作队列的长度只是一个方面。然而排除故障时查看从提交任务到实际执行之间的时间差就显得非常重要了。这个时间差越接近0就越好(说明正好线程池中有空闲的线程),否则任务要入队的话这个时间就会增加了。再进一步说,如果线程池不是固定线程数的话,执行新的任务还得新创建一个线程,这个同样也会消耗一定的时间。为了能更好地监控这项指标,可以包装ExecutorService(装饰模式)

    public class WaitTimeMonitoringExecutorService implements ExecutorService {  
       
        private final ExecutorService target;  
       
        public WaitTimeMonitoringExecutorService(ExecutorService target) {  
            this.target = target;  
        }  
       
        @Override  
        public <T> Future<T> submit(Callable<T> task) {  
            final long startTime = System.currentTimeMillis();  
            return target.submit(() -> {  
               final long queueDuration = System.currentTimeMillis() - startTime;  
               log.debug("Task {} spent {}ms in queue", task, queueDuration);  
               return task.call();  
               }  
            );  
        }  
       
        @Override  
        public <T> Future<T> submit(Runnable task, T result) {  
            return submit(() -> {  
                task.run();  
                return result;  
            });  
        }  
       
        @Override  
        public Future<?> submit(Runnable task) {  
            return submit(new Callable<Void>() {  
                @Override  
                public Void call() throws Exception {  
                    task.run();  
                    return null;  
                }  
            });  
        }  
       
        //...  
       
    }  
    
  2. 当我们将任务提交给线程池的时候,便立即开始记录它的时间。一旦这个任务被取出并开始执行时便停止计时。不要被代码中的startTime和queueDuration这两个变量搞混了。事实上它们是在两个不同的线程中进行求值的,通常都会差个毫秒级或者秒级

优先使用CompletableFuture

  1. 在Java 8引入了更强大CompletableFuture。 请尽可能使用它。ExecutorService并没有扩展以支持这个增强型的接口,因此你得自己动手了。这么写是不行的了

    final CompletableFuture<BigDecimal> future =   
        CompletableFuture.supplyAsync(this::calculate, executorService);  
    

同步队列

  1. SynchronousQueue是一个非常有意思的BlockingQueue。它本身甚至都算不上是一个数据结构。最好的解释就是它是一个容量为0的队列。这里引用下Java文档中的一段话:每一个insert操作都需要等待另一个线程的一个对应的remove操作,反之亦然。同步队列内部不会有任何空间,甚至连一个位置也没有。你无法对同步队列执行peek操作,因为仅当你要移除一个元素的时候才存在这么个元素;如果没有别的线程在尝试移除一个元素你也无法往里面插入元素;你也无法对它进行遍历,因为它什么都没有。同步队列与CSP和Ada中所用到的集结管道(rendezvous channel)有异曲同工之妙

  2. 在要么执行要么马上丢弃后台执行的场景下同步队列非常有用。我们创建了一个拥有两个线程的线程池,以及一个SynchronousQueue。由于SynchronousQueue本质上是一个容量为0的队列,因此这个ExecutorService只有当有空闲线程的时候才能接受新的任务。如果所有的线程都在忙,新的任务便会马上被拒绝掉,不会进行等待

    BlockingQueue<Runnable> queue = new SynchronousQueue<>();  
    ExecutorService executorService = new ThreadPoolExecutor(n, n,  
            0L, TimeUnit.MILLISECONDS,  
            queue);
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值