Java多线程案例之线程池

目录

一、介绍线程池

1.1 为什么线程池比创建新线程快?

1.2 线程池的优点

 二、标准库中的线程池

2.1 为何需要使用工厂模式创建线程而不使用常见的 构造方法呢?

三、模拟实现线程池

3.1 为什么N个任务不对应N个线程来完成任务呢?

3.2 如何把N个任务分配给M个线程执行呢?

3.3 代码实现线程池:

 四、ThreadPoolExecutor(线程池)的工作流程和拒绝策略

4.1 Executors 创建线程池的几种方式

4.2 认识ThreadPoolExecutor 

4.3  RejectedExecutionHandler (线程池的拒绝策略) 

4.3.1 拒绝策略的应用场景

4.3.2 实际开发中,线程池的线程数目,如何确认?设计为几比较合适?(延申)

4.4 线程池的工作流程

前言:在讲解线程池的概念之前,我们先来谈谈线程和进程,我们知道线程诞生的目的其实是因为进程太过重量了,导致系统在 销毁/创建 进程时比较低效(具体指 内存资源的申请和释放)。

而线程,其实做到了共享内存资源,新的线程复用之前的资源(也就不必再申请了)。

但是如果线程创建的速率进一步的频繁,此时线程创建销毁的开销仍然不能忽略,这时就需要线程池来进一步优化这里的速度了。

一、介绍线程池

线程池是一种线程使用模式。当线程过多时会带来调度开销,进而影响缓存局限性和整体性能。

线程池维护这多个线程,等待监督管理分配可并发执行的任务,避免了处理短时间任务时创建与销毁线程的代价,线程池不仅能保证内核的充分利用,还能防止过分调度。

1.1 为什么线程池比创建新线程快?

首先我们要明白线程池中取线程的过程,池中的线程执行任务时候,不需要再重新创建线程,而是直接从池子里取出一个现成的线程,直接使用,使用之后并非销毁,而是放回到线程池中

回到上面的问题,那 为什么直接取一个现成的线程要快一点呢?

:可能有人会回答,创建线程需要申请资源,因此利用现有的线程池比创建线程更轻量,虽然创建线程确实是有申请资源,但这并不是最主要的。

那就先说结论吧,使用线程池是纯用户态操作,而创建线程需要经历用户态到内核态的转变。因此,线程池这种纯用户态要比后者快。

用户态:每个线程都自己执行自己的逻辑。

内核态;一个系统只有这一份内核在执行逻辑,这个内核需要给所有的进程提高服务。

下面进一步讲解线程和线程池的运作过程:

创建线程:我们知道线程本质上是PCB(内核中的数据结构),当应用程序发起一个 创建线程 的行为,应用程序就需要通过系统调度,进入到操作系统内核中执行,内核完成PCB的创建之后,再把PCB加入到调度队列中,之后再返回给应用程序。

线程池:从线程池中取线程,把线程放回线程池,都在操作系统内核中实现,这是纯用户态实现的逻辑,用户态的每个进程都是自己执行自己的逻辑,运行较快。

1.2 线程池的优点

  1. 降低资源消耗:减少线程的创建和销毁带来的性能开销。
  2. 提高响应速度:当任务来时可以直接使用,不用等待线程创建。
  3. 可管理性:进行统一的分配,监控,避免大量的线程间互相抢占资源导致的阻塞现象。

 二、标准库中的线程池

标准库中我们一般通过工厂模式(不通过new关键字而使用工厂类来创建对象,能够让创建对象变得简单而且更方便的修改对象,属于创建型模式。)的方法来创建线程池:

//此处创建线程池,没有显示的new,而是通过另外Executors类的静态方法newCachedThreadPool来完成
//这种做法叫做工厂模式,对应此处的newCachedThreadPool()方法就是工厂方法。
        ExecutorService pool = Executors.newCachedThreadPool();

2.1 为何需要使用工厂模式创建线程而不使用常见的 构造方法呢?

因为构造方法常见的提供多种构造实例的方式是:重载,而重载要求 参数的个数/类型 不同。

这就带来了一些限制。

比如:

此处的两个版本的构造方法就无法形成重载。解决这个问题就是使用普通方法来代替构造方法,使普通方法在里面分别构造Point对象,再通过一些其他手段进行设置即可。

线程池单纯的使用也是非常简单,使用submit方法,把任务交给线程池即可,线程池中会有一些线程来负责完成这里的任务。

        ExecutorService pool = Executors.newCachedThreadPool();
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });

三、模拟实现线程池

我们知道一个线程池可以同时提交N个任务,对应的线程池中有M个线程来负责完成这N个任务。

3.1 为什么N个任务不对应N个线程来完成任务呢?

这时因为如果这样设置的话,由于开发中很多时候任务难度是不同的,如果设置N对N关系的话,可能会出现有的线程很早就完成任务了,然后就在旁边干等着,造成 一个线程干活,多个线程围观的场面,这是很尴尬的。

3.2 如何把N个任务分配给M个线程执行呢?

利用生产者消费者模型即可,首要步骤就是实现一个阻塞队列,每个被提交的任务,都被放入阻塞队列中,让M个线程来取队列中的元素,如果队列为空,M个线程就阻塞等待,如果队列不为空,每个线程就来领取任务,执行完手头上的任务时,再去取下一个任务,直到队列为空,线程继续阻塞等待。

3.3 代码实现线程池:

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;


class MyThreadPool {
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }

    public MyThreadPool(int m) {
        //在构造方法中,创建出M个线程,负责完成工作。
        for (int i = 0; i < m; i++) {
            Thread t = new Thread(() -> {
                while(true) {
                    try {
                         Runnable runnable = queue.take();
                         runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
        }
    }
}
public class Demo25 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            int taskId = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("执行当前任务"+taskId+"当前线程"+Thread.currentThread().getName());
                }
            });
        }
    }
}

运行结果:

 四、ThreadPoolExecutor(线程池)的工作流程和拒绝策略

在认识ThreadPoolExecutor之前,我们在简单回顾一下 ExecutorServiceExecutors。

  • ExecutorService 表示一个线程的实例
  • Executors 是一个工厂类,能够创建出几种不同风格的线程池。
  • ExecutorService 的submit 方法能够向线程池中提交若干个任务。
public class Demo26 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }
}

4.1 Executors 创建线程池的几种方式

  • newFixedThreadPool : 创建固定线程数的线程池。
  • newCachedThreadPool: 创建线程数且动态增长的线程池。
  • newSingleThreadExecutor: 创建只包含单个线程的线程池。
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令,可以理解为进阶版本的 Timer。

Executors 本质上是 ThreadPoolExecutor 类的封装。

4.2 认识ThreadPoolExecutor 

相比于Executors ,ThreadPoolExecutor提供了更多可选参数,可以进一步细化线程池行为的设定。

理解上述ThreadPoolExecutor 构造方法的参数

把创建一个线程池想象成开个公司,每个员工相当于一个线程:

  • corePoolSize : 正式员工的数量(正式员工,一旦录用,永不辞退)
  • maximumPoolSize :正式员工+临时工的数目 (临时工:一段时间不干活,就会被辞退(销毁))。
  • KeepAliveTime:临时工允许的空闲时间。
  • unit:keepaliveTime 的时间单位,是秒,分钟等。
  • workQueue:传递任务的阻塞队列(手动),如果不传,线程池内部会自己创建(传与不传要看具体的业务逻辑,否则会起到画蛇添足的效果。
  • threadFactory:创建线程的工厂(本质是一个接口),描述线程是如何创建的,参与了哪些具体的创建线程工作。(程序员可以指定线程的创建策略)

4.3  RejectedExecutionHandler (线程池的拒绝策略) 

4.3.1 拒绝策略的应用场景

当线程池的任务队列已经满的时候(工作线程已经忙不过来了),这时还有新的任务被添加进来时候使用的策略。

该策略对于实现”高并发“服务器,具有非常大的意义。

具体有如下四个:

  • AbortPolicy():超过负荷,线程池抛出异常并且终止任务。
  • CallerRunsPolicy():把任务交给添加该任务的线程执行。
  • DIscardOldestPolicy(): 丢弃队列中最老的任务。
  • DiscardPolicy(): 丢弃新来的任务。

当然在实际开发的过程中,需要根据需求,来决定使用哪些策略来进行处理。

4.3.2 实际开发中,线程池的线程数目,如何确认?设计为几比较合适?(延申)

答案:无法确认。

因为需要从两个方面考虑:

  • 主机的CPU配置
  • 程序的执行特点(具体是干了什么,执行了CPU密集型的任务,还是IO密集型任务。)

 在两者都不确认的情况下是无法直接确认个数的。

代码可能执行CPU密集型任务(做了大量的算术运算和逻辑判断),亦或是IO密集型任务(做了大量的读写网卡/读写硬盘)。

而程序是很难量化两种任务的比例的。

注释:N为CPU核数。

假设是100%计算密集型,可以将线程数目设置为N(CPU数量)+ 2(只是推荐)。多余的2是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停带来的影响。

假设 任务是10%是CPU密集型,90%是操作IO(不使用CPU),即使把数目设置为5N,亦或是10N也都是可以的。

当然,以上的核数只是推荐,工作中我们要进行验证,对代码进行性能化测试后(分配不同数量的线程数目),再选择一个最优配置。

4.4 线程池的工作流程

  1. 当一个新任务加入时,先判断当前线程池中的线程数是否大于核心线程数corePoolSize),如果结果为false,则新建一个核心线程。
  2. 如果结果为true,则判断任务队列是否为满,如果结果为false,则把任务添加到任务对垒中等待线程执行即可。
  3. 如果结果为true,则判断当前线程数量是否超过最大线程数量maximumPoolSize),如果结果为false,则新建临时线程执行任务。
  4. 如果结果为true,执行拒绝策略

猜你喜欢

转载自blog.csdn.net/qq_63218110/article/details/128780132