美文网首页
Java并发知识(4)(线程池)

Java并发知识(4)(线程池)

作者: _River_ | 来源:发表于2021-04-22 22:20 被阅读0次
    1:为什么要用线程池
    1:降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
    2:提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
    3:提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
    
    使用多线程前:
    任务1 ——> 任务2 ——> 任务3
    使用多线程后:
    1:任务1 任务2 任务3 同时执行;
    2:使用CountDownLatch等待他们完全执行结束;
    3:话费时间为最长任务的消耗时间;
    
    2:实现 Runnable 接口和 Callable 接口的区别
    Runnable 接口或 Callable 接口实现类都可以
    被 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。
    两者的区别在于 Runnable 接口不会返回结果但是 Callable 接口可以返回结果。
    
    注意:工具类Executors可以实现Runnable对象和Callable对象之间的相互转换。
    
    3:执行 execute() 方法和 submit() 方法的区别是什么
     1:execute() 方法用于提交不需要返回值的任务,
        无法判断任务是否被线程池执行成功与否;
     2:submit() 方法用于提交需要返回值的任务。
        线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功(里面的get()方法会阻塞到当前线程完成任务)
    
    4:如何创建线程池
     创建线程池的方式:
     1:通过构造方法实现
     2:通过 Executor 框架的工具类 Executors 来实现(一共有4种方法)
        
         CachedThreadPool: 
            该方法返回一个可根据实际情况调整线程数量的线程池。(没有设置 线程数的Integer.MAX_VALUE)
            线程池的线程数量不确定, 但若有空闲线程可以复用,则会优先使用可复用的线程。
            若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。
            所有线程在当前任务执行完毕后,将返回线程池进行复用。
        
        FixedThreadPool :
            该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。
            当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。
            若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
                 
         SingleThreadExecutor(了解): 
            方法返回一个只有一个线程的线程池。
            若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
            
        ScheduledThreadPool(了解):
            在CachedThreadPool一致,((没有设置 线程数的Integer.MAX_VALUE))
            但可以提供线程进入逻辑后延迟执行的功能。
    
    5:线程池的最佳实践:
       《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式
    这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
    
    Executors 返回线程池对象的弊端如下:
         FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
        CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
    
    说白了就是:需要使用有界队列,需要控制线程创建数量。
    
    除了避免 OOM 的原因之外,不推荐使用 Executors提供的两种快捷的线程池的原因还有:
    1:实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
    2:我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
    

    1:线程池的监控
    可以使用自带API做一个简陋的监控
    或者使用其他一些监控软件进行监控
    
    //当前线程数
    log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());
    //活动线程数
    log.info("Active Threads: {}", threadPool.getActiveCount());
    //已经执行完成的任务数
    log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());
    //正在排队中的任务数
    log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
    
    
    2:线程池预热:
    线程池被创建后如果没有任务过来,里面是不会有线程的。
    如果需要预热的话可以调用下面的两个方法:
    启动所有核心线程:public int prestartAllCoreThreads()
    启动一个核心线程:public int prestartCoreThreads()
    
    3:建议不同类别的业务用不同的线程池
        一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,
        因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。
    

    由于使用了共同的一个线程池导致的死锁问题:
    试想这样一种极端情况:
    假如我们线程池的核心线程数为 n(假设为2),父任务(扣费任务)数量为 n(假设为2)
    父任务下面有子任务(扣费任务下的子任务);
    
    两个父任务进入,获取两个线程资源,这个时候已经没有线程资源了,
    然后要执行子任务了,子任务一直获取不到线程资源,在父任务的线程资源又不能释放。
    直接导致死锁了。
    
    解决方案:
    是新增加一个用于执行子任务的线程池专门为其服务。
    
    4: 给线程池命名
    初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
    默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。
    
    //利用 guava 的 ThreadFactoryBuilder
    ThreadFactory threadFactory = new ThreadFactoryBuilder()
                                                        .setNameFormat(threadNamePrefix + "-%d")
                                                        .setDaemon(true).build();
    ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)
    
    6:如何正确配置线程池参数
    很多人甚至可能都会觉得把线程池配置过大一点比较好!
    我觉得这明显是有问题的。
    就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。
    你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?
    
    对于多线程这个场景来说主要是增加了上下文切换成本
    

    上下文切换:
    多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,
    为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。
    当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
    
    概括来说就是:
    当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。
    任务从保存到再加载的过程就是一次上下文切换。
    
    上下文切换通常是计算密集型的。 
    也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。
    上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
    
    Linux 相比与其他操作系统(包括其他类 Unix 系统)的一个很重要的优点:其上下文切换和模式切换的时间消耗非常少
    

    解决方案1:重新设置参数 重启服务
    
    有一个简单并且适用面比较广的公式:
    
    CPU 密集型任务(N+1): 
    这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,
    比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。
    一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
    
    I/O 密集型任务(2N): 
    这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,
    这时就可以将 CPU 交出给其它线程使用
    因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是2N。
    
        方案1:和实际业务场景有所偏离
        方案2:有点像是把任务都当做 IO 密集型去处理了
        方案3:理想状态,流量是不可能这么均衡的。
    
    如何判断是 CPU 密集任务还是 IO 密集任务?
    
    CPU 密集型任务:
                        单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。
    IO 密集型任务:
                    网络读取,文件读取这类
                    这类任务的特点是 CPU 计算耗费时间很少,大部分时间都花在了等待 IO 操作完成上。
    

     解决方案2:后台设置(美团的骚操作)
     这里放上原文链接:
     https://mp.weixin.qq.com/s?__biz=Mzg3NjU3NTkwMQ==&mid=2247505103&idx=1&sn=a041dbec689cec4f1bbc99220baa7219&source=41#wechat_redirect
     
     美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。(修改代码 设置这3个参数为可变)
     
     这三个核心参数是:
        corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
        maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
        workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,任务就会被存放在队列中。
    
    应用场景:
    项目里面的定时任务用到了线程池,但是核心线程数和队列长度都设置的比较大,
    某一次任务触发后查出了大批数据,通过线程池提交任务,每个任务里面都会调用下游服务,
    导致下游服务长时间的压力过大,也没有做限流,所以影响了其对外提供的其他功能。
    
    假设场景:
    然后给它塞 15 个耗时 10 秒的任务,直接让它 5 个最大线程都在工作,队列长度 10 个都塞满。
    当前的情况下,队列里面的 10 个,前 5 个在 10 秒后会被执行,后 5 个在 20 秒后会被执行。
    再加上最大线程数正在执行的 5 个,15 个任务全部执行完全需要 3 个 10 秒即 30 秒的时间。
    
    假如:如果我们把核心线程数和最大线程数都修改为 10。(setCorePoolSize方法)
    那么 10 个任务会直接被 10 个最大线程数接管,10 秒就会被处理完成。
    剩下的 5 个任务在队列里面,在 10 秒后被执行完成。
    所以,15 个任务执行完成需要 2 个 10 秒即 20 秒的时间处理完成了。
    

    setCorePoolSize方法讲解:
    
    在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,
    并且基于当前值和原始值的比较结果采取不同的处理策略
    

     setMaximumPoolSize方法讲解:
    
    首先是参数合法性校验。
    然后用传递进来的值,覆盖原来的值。
    判断工作线程是否是大于最大线程数,如果大于,则对空闲线程发起中断请求。
    

    那么在动态设置的时候需要注意什么关键点:
    
    假设场景:
    改变之前的核心线程数是 2,最大线程数为 5,我们动态修改核心线程数为 10。
    但是从日志还是可以看出,修改之后核心线程数确实变成了 10,但活跃线程数还是为 5。
        
    如果工作线程数大于最大线程数,则对工作线程数量进行减一操作,然后返回 null。
    设置核心线程数的时候,同时设置最大线程数即可。(设置为相同的值)
    

    如果调整之后把活动线程数设置的值太大了,
    岂不是业务低峰期我们还需要人工把值调的小一点?
    
    corePoolSize 参数的含义时的注解:
    当 allowCoreThreadTimeOut 参数设置为 true 的时候,
    某个核心线程在空闲了 keepAliveTime 的时间后也会被回收的,
    相当于线程池自动给你动态修改了。
    
    注意:
       正常情况下是不会进行使用的,因为线程池的主要作用就是存放核心线程来
       减少线程的创建销毁时性能浪费。
       但在这种根据时间段或者人工  来主动设置 线程池的线程数 
       可以确认服务器的存活线程数没有那么多,避免服务器压力。       
    

    如何动态指定队列长度?
    无法设置队列长度(里面的capacity被final修饰 容量空间无法修改):
    
    美团自定义 ResizableCapacityLinkedBlockIngQueue 的队列
    主要就是把LinkedBlockingQueue的capacity 字段的final关键字修饰给去掉了,让它变为可变的
    

    最终成果:
    通过监控器监控线程,如何通过修改:corePoolSize  maximumPoolSize workQueue
    确保线程池的高效以及安全使用。
    

    相关文章

      网友评论

          本文标题:Java并发知识(4)(线程池)

          本文链接:https://www.haomeiwen.com/subject/dxtslltx.html