多线程如何从理论到实战

一. 回顾

关于线程相关的一些基础知识,本篇不再过多阐述,首先我们通过几个简单的问题,复习一下线程相关的一些基础知识。

1.进程和线程分别是什么?
进程:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
2.进程、线程的例子?
进程:运行的QQ、迅雷、Word等应用进程,进程是系统级别的
线程:QQ多个聊天窗口、迅雷下载多个文件等
3.线程的状态(生命周期)
线程的状态枚举:java.lang.Thread.State 中状态值:
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED
线程状态
4.wait()和sleep()区别
sleep()是Thread类的方法;wait()是Object类的方法
sleep()方法使程序暂停执行指定的时间,让出cpu给其它线程,但是它的监控状态依然保持,也就说是不会释放对象锁,当指定的时间到了又会自动恢复运行状态;wait()方法使线程释放对象锁,进入此对象的等待锁定池,只有针对此对象调用notify()方法后,本线程才会重新进入准备获取对象锁的状态。

  1. 原理不同。sleep()方法是Thread类的静态方法,是线程用来控制自身流程的,他会使此线程暂停执行一段时间,而把执行机会让给其他线程,等到计时时间一到,此线程会自动苏醒。例如,当线程执行报时功能时,每一秒钟打印出一个时间,那么此时就需要在打印方法前面加一个sleep()方法,以便让自己每隔一秒执行一次,该过程如同闹钟一样。而wait()方法是object类的方法,用于线程间通信,这个方法会使当前拥有该对象锁的进程等待,直到其他线程调用notify()方法或者notifyAll()时才醒来,不过开发人员也可以给他指定一个时间,自动醒来。
  2. 对锁的 处理机制不同。由于sleep()方法的主要作用是让线程暂停执行一段时间,时间一到则自动恢复,不涉及线程间的通信,因此,调用sleep()方法并不会释放锁。而wait()方法则不同,当调用wait()方法后,线程会释放掉他所占用的锁,从而使线程所在对象中的其他synchronized数据可以被其他线程使用。
  3. 使用区域不同。wait()方法必须放在同步控制方法和同步代码块中使用,sleep()方法则可以放在任何地方使用。sleep()方法必须捕获异常,而wait()、notify()、notifyAll()不需要捕获异常。在sleep的过程中,有可能被其他对象调用他的interrupt(),产生InterruptedException。由于sleep不会释放锁标志,容易导致死锁问题的发生,因此一般情况下,推荐使用wait()方法。

通过上面几个问题复习了一下基本概念,如果对这些概念有些模糊,小伙伴可以通过专栏简单看前几篇回忆一下。
Java多线程

在学习多线程的时候,我们都知道wait()、notfiy()、notifyAll()一些操作线程的方法,但是工作中我们其实很少直接使用这些方法进行多线的操作。我们使用多线程的时候要注意几点:
1.在高内聚低耦合的前提下,通过线程操作资源类
竞争资源+操作(对外暴露的调用方法)=资源类
2.操作方法实现
判断->业务逻辑->通知唤醒
判断时,防止虚假唤醒,应使用while()循环判断
3.使用JDK8 lock
synchronized-> wait\notify 替换==> lock->await\signal

在实际工作中更加关心业务逻辑的实现,直接使用这些基础方法很难写出高效且安全的多线程代码,一般都是通过线程池进行多线程实现,那么有没有什么规范可以参考呐?
当然,在《阿里巴巴Java开发手册》并发处理章节中,对于并发中的规约如下:
阿里巴巴开发手册
阿里巴巴开发手册中明确指出线程资源必须通过线程池提供,也就说我们在编码中应该通过线程池而不是new Thead()的方式。使用线程池的时候也不能通过Executors去创建。ok,进入正题–>线程池

二. 线程池

线程池的优势:

(1)、降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
(2)、提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
(3)方便线程并发数的管控。因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换(cpu切换线程是有时间成本的(需要保持当前执行线程的现场,并恢复要执行线程的现场))。
(4)提供更强大的功能,延时定时线程池。

这些都是套话,死记硬背当然是不可能。其实提到池,池化思想是很普遍的,如线程池、数据库连接池等,这些池化的优点也都是基本相通的.
1.通过资源的重用降低系统的资源消耗;
2.通过提前创建资源提高系统的响应速度;
3.提供更加高效安全的管理方法等;

线程池的使用

可以通过Executors工厂方法创建

方法 特点
newCachedThreadPool() 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程;
newFixedThreadPool(int) 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。池中的线程将一直存在,直到它显式出现shutdown
newSingleThreadPool() 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行
newScheduledThreadPool() 创建一个定长线程池,支持定时及周期性任务执行

通过上面四种方法基本可以创建平时所需的线程池,在配合常见如下api,基本可以完成任务了。

方法 说明
execute(Runnable) 执行任务
shutdown() 不在接受新的线程,并且等待之前提交的线程都执行完在关闭
shutdownNow() 直接关闭活跃状态的所有的线程 , 并返回等待中的线程
getActiveCount() 获取线程池活动线程数量

线程池api很多,只列举几个。通过Executors创建所需线程池,搭配基本api,貌似已经可以完成业务逻辑代码了,万事大吉了。
我们当然不能停下思考的脚本,为什么开发手册强制不能使用Executors创建线程池呐?

Executors 返回的线程池对象的弊端如下
1) FixedThreadPool 和 SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM
2) CachedThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM

原来这样会造成OOM。为什么呐?我们以CachedThreadPool为例看一下创建线程池方法的源码。

    //1.java.util.concurrent.Executors#newCachedThreadPool()
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
	//2.java.util.concurrent.ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, java.util.concurrent.TimeUnit, java.util.concurrent.BlockingQueue<java.lang.Runnable>)
	public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }

可以看到最终调用的是 java.util.concurrent.ThreadPoolExecutor中有7个参数的构造方法。查看其他方法FixedThreadPool、SingleThreadPool等,同样发现调用的都是ThreadPoolExecutor中同一个7个参数的构造方法。
在这里插入图片描述
在这里插入图片描述
只不过有的参数设置的默认值不同而已。

看到这里,我们如果了解了这7个参数,就能知道为什么阿里巴巴开发手册要说会造成OOM了,通过这7个参数,我们也能明白线程池的原理和使用重点了

三. 线程池7大参数

线程池七大参数
1.corePoolSize
线程池中常驻核心线程数;
创建线程池后,当有请求任务来之后,就会安排池中线程去执行请求任务,近似理解为今日当值线程。
当线程池中的线程数目达到了corePoolSize后,就会把任务放到缓存队列中;
2.maximumPoolSize
线程池中能容纳同时执行的最大线程数,此值大于等于1
3.keepAliveTime
多余空闲线程的存活时间;当前线程池的数量超过corePoolSize时,当空闲时间达到keepAliveTime值时,多余空闲线程会被销毁直到只剩下corePoolSize个线程为止;
4.unit
keepAliveTime的时间单位

TimeUnit.DAYS;               //天
TimeUnit.HOURS;             //小时
TimeUnit.MINUTES;           //分钟
TimeUnit.SECONDS;           //秒
TimeUnit.MILLISECONDS;      //毫秒
TimeUnit.MICROSECONDS;      //微妙
TimeUnit.NANOSECONDS;       //纳秒

5.workQueue
任务队列,被提交但尚未被执行的任务存放在workQueue中;
6.threadFactory
表示生成线程池中工作线程的线程工厂,用于创建线程,一般默认即可;
7.handler
拒绝策略,表示线程大于等于线程池的最大线程数时如何拒绝请求执行的任务的策略

拒绝策略

拒绝策略

策略 概述
ThreadPoolExecutor.AbortPolicy 丢弃任务并抛出RejectedExecutionException异常(默认)
ThreadPoolExecutor.DiscardPolicy 丢弃任务,但是不抛出异常
ThreadPoolExecutor.DiscardOldestPolicy 丢弃队列最前面的任务,然后重新提交被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy 由调用线程(提交任务的线程)处理该任务,不会抛出异常,不会抛弃任务(常用)

了解了7个参数,再回头来看开发手册中所说的造成OOM的情况
1.FixedThreadPool 和 SingleThreadPool的workQueuenew LinkedBlockingQueue()请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求任务,导致 OOM

    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

2.CachedThreadPool:maximumPoolSize
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM
在这里插入图片描述
现在串联一下7个参数的作用,描述一下线程池工作原理,为实战做最后的准备。

四. 线程池工作原理

线程池工作原理
1.在创建了线程池后,开始等待请求;
2.当调用execute()方法添加一个请求任务是,线程池会做出如下判断:

2.1. 如果此时线程池中的数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
2.2. 如果此时线程池中的数量等于corePoolSize,但是缓冲队列workQueue未满,那么任务被放入缓冲队列。
2.3. 如果此时线程池中的数量大于等于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。
2.4. 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量等于maximumPoolSize,那么通过handler所指定的策略来处理此任务。
2.5. 当线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数。

3.当一个线程执行完任务时,会从等待队列中取下一个任务来执行;
4.当一个线程无任务运行,空闲超过一定时间(keepAliveTime)时,线程会判断:
如果当前运行的线程数大于corePoolSize,那么这个线程就被回收,所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小

注意

  • 处理任务判断的优先级为 核心线程corePoolSize、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler处理被拒绝的任务。
  • workQueue使用的是无限队列时,maximumPoolSize参数就变的无意义了,比如new LinkedBlockingQueue(),或者new ArrayBlockingQueue(Integer.MAX_VALUE);
  • 使用SynchronousQueue队列时由于该队列没有容量的特性,所以不会对任务进行排队,如果线程池中没有空闲线程,会立即创建一个新线程来接收这个任务,maximumPoolSize要设置大一点。
  • 线程和最大线程数量相等时keepAliveTime无作用.

五. 实战

多线程在实际工作中的使用一定要结合业务需求,不要为了使用多线程而使用多线程。如果目前系统性能正常,能够满足系统预期要求,并且可以支撑未来的业务要求,就没有必要使用。如果系统性能出现了瓶颈,需要使用多线程,那么高效安全的多线程代码也不是一撮而就的,需要结合业务实际需求,不断优化,不断调试、迭代,并随着系统业务量的增加,不断迭代优化。说这些主要是表达,不要为了技术而技术,需求驱动我觉得比较好,毕竟多线程意味着更多的开发、测试、维护的工作量。
ok,点到为止,见仁见智吧。
以我前段时间遇到的项目经历做为案例分析一下吧。

项目背景

根据系统中预先维护的基础数据,定时为系统生成工单并自动派发到工作人员。
1.0:初始时,敏捷迭代,快速开发,考虑初期用户量不大,没有使用多线程,配合XxlJob定时调度框架,每天凌晨生成并分派工单。满足需求
2.0:基础数据越来越多,用户量也不多增加。每个工作人员每天应分派4-6个工单,平均5个工单,有的生成工单,但是不符合派单逻辑,分派不到具体人员。预测系统中每日大约需要生成5W个工单,而每个工单的平均有10-20个工作项,每日工作项月50w-60w个。即使优化sql,批量提交、优化代码,系统在凌晨时压力也比较大,定时任务的执行时间也是越来越长,有时因为执行时间过长,造成任务崩溃。不得不进行优化。

解决方案:

分析数据发现可以将任务生成以基础数据中某个维度划分开,拆分为一个个独立的任务。使用多线程,每个维度任务划分为一个线程,互不干扰。这个维度的划分,需要结合自己项目的实际需求了,一定要慎重选择,一个好的提交维度切入点,会使多线程代码更加高效,事务控制更加合理,不会因为某个线程失败,导致其他线程异常,甚至垃圾数据,一个好的维度已经成功了一半,如果划分线程的维度不好,需求轻微一改动,又是一场恶战。这里分析得出的维度为区域,每个区域一个线程。

编码实战:

结合Springboot框架,这里使用ThreadPoolTaskExecutor,spring包下的,是sring为我们提供的线程池类,内部也是封装的ThreadPoolExecutor,这里直接继承,加入一些日志使用即可。
MyThreadPoolExecutor.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.util.concurrent.ListenableFuture;

import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
public class MyThreadPoolExecutor extends ThreadPoolTaskExecutor{
    private void showThreadPoolInfo(String prefix){
        ThreadPoolExecutor threadPoolExecutor = getThreadPoolExecutor();

        if(null==threadPoolExecutor){
            return;
        }
        log.info("{}, {},未完成的任务数量 [{}], 完成任务数 [{}], 线程池中alive的线程数量 [{}], 队列大小 [{}]",
                this.getThreadNamePrefix(),
                prefix,
                threadPoolExecutor.getTaskCount(),
                threadPoolExecutor.getCompletedTaskCount(),
                threadPoolExecutor.getActiveCount(),
                threadPoolExecutor.getQueue().size());
    }

    @Override
    public void execute(Runnable task) {
        showThreadPoolInfo("Runnable任务");
        super.execute(task);
    }

    @Override
    public void execute(Runnable task, long startTimeout) {
        showThreadPoolInfo("Runnable任务");
        super.execute(task, startTimeout);
    }

    @Override
    public Future<?> submit(Runnable task) {
        showThreadPoolInfo("1. do submit");
        return super.submit(task);
    }

    @Override
    public <T> Future<T> submit(Callable<T> task) {
        showThreadPoolInfo("2. do submit");
        return super.submit(task);
    }

    @Override
    public ListenableFuture<?> submitListenable(Runnable task) {
        showThreadPoolInfo("1. do submitListenable");
        return super.submitListenable(task);
    }

    @Override
    public <T> ListenableFuture<T> submitListenable(Callable<T> task) {
        showThreadPoolInfo("2. do submitListenable");
        return super.submitListenable(task);
    }
}

配置及使用:

1.通过SpringBoot @Configuration 配置

@Configuration
@EnableAsync
public class ExecutorConfig {

    @Bean("threadPool")
    public ThreadPoolTaskExecutor asyncServiceExecutor() {
        ThreadPoolTaskExecutor executor = new VisiableThreadPoolTaskExecutorHelper();
        //配置核心线程数-示例大小,按需配置
        executor.setCorePoolSize(5);
        //配置最大线程数-示例大小,按需配置
        executor.setMaxPoolSize(5);
        //配置空闲线程存活时间
        executor.setKeepAliveSeconds(100);
        //配置队列大小-示例大小,按需配置
        executor.setQueueCapacity(1000);
        //配置线程池中的线程的名称前缀
        executor.setThreadNamePrefix("demo-线程池执行");
        // 配置拒绝策略:当pool已经达到max size的时候,如何处理新任务
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //执行初始化
        executor.initialize();
        return executor;
    }
}

当然,也可以通过xml配置,不在演示xml配置

2.使用:在需要使用的时候自动注入的方式注入线程池即可

@Resource(name="threadPool")
ThreadPoolTaskExecutor taskExecutor;
// 或者可以直接@Autowried
@AutoWired
ThreadPoolTaskExecutor taskExecutor

我使用的是第一种配置bean的方式。

实践效果:

通过调度框架执行的时间来看,运行时间缩短了x倍,产品经理露出大姨夫的微笑(* ̄︶ ̄)~

线程池参数设置

队列大小、拒绝策略按照系统业务需求设置即可。

如何合理设置线程池的核心线程数、最大线程数?

这个也是要根据线程池执行的任务的实际情况进行分析后进行设置。

  • 如果是CPU密集型任务应配置尽可能小的线程,如配置CPU个数+1的线程数;
//获取CPU核数
int availableProcessors = Runtime.getRuntime().availableProcessors();
  • IO密集型任务应配置尽可能多的线程,因为IO操作不占用CPU,不要让CPU闲下来,应加大线程数量,如配置两倍CPU个数+1;
  • 混合型的任务,如果可以拆分,拆分成IO密集型和CPU密集型分别处理,前提是两者运行的时间是差不多的,如果处理时间相差很大,则没必要拆分了。

六.结语

合理、适度、适时、正确的使用线程池,提高系统性能。如果系统业务量还在增长,多线程优化效果不明显;出现任务间相互依赖,某个任务依赖上一步任务的执行结果等复杂情况…
继续演化的话,可以将任务进行更细粒度的拆分,一个任务拆分为多个步骤执行。可以考虑使用SpringBatch批处理框架

Spring Batch是一个轻量级,全面的批处理框架,旨在开发对企业系统日常运营至关重要的强大批处理应用程序。 Spring Batch构建了人们期望的Spring Framework特性(生产力,基于POJO的开发方法和一般易用性),同时使开发人员可以在必要时轻松访问和利用更高级的企业服务。 spring batch是spring提供的一个数据处理框架。企业域中的许多应用程序需要批量处理才能在关键任务环境中执行业务操作。

注意Springbatch只是批处理框架,不具备调度功能,可以搭配Quartz等完成定时调度功能。

溜了溜了~,眼疼吖在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章