详细又简单的线程池的介绍

1. 简单介绍

线程池:顾名思义,就是放线程的池子。提前准备好线程,用户在使用线程时就不用直接从系统申请,而是从池子中拿。线程不用了,就放回到池子里。

2. 使用线程池的原因

使用线程池就是为了提高效率。线程的创建虽然比进程轻量,但是在频繁创建的情况下,开销也是不可忽略的。
那么凭什么从池子中拿线程就比从系统创建线程要更高效?
从线程池中获取线程相比于每次都创建新线程,可以带来一些性能上的优势和效率提升,主要有以下几个原因:

  1. 重用线程:线程池中的线程是可重用的。当有新任务到达时,线程池会尝试重用空闲的线程来执行任务,而不是每次都创建新线程。这避免了频繁地创建和销毁线程的开销,减少了系统资源的消耗。

  2. 控制线程数量:线程池可以限制并控制线程的数量。通过设置线程池的核心线程数、最大线程数和任务队列等参数,可以合理地管理线程的数量,避免线程数量过多导致系统负载过高,或线程数量过少导致任务等待的时间过长。

  3. 减少线程创建销毁的开销:线程的创建和销毁是一项开销较高的操作。通过线程池,可以避免频繁地创建和销毁线程,而是将这些操作集中在线程池的生命周期中。这样可以减少创建和销毁线程所带来的开销,提高系统的性能和响应速度。

  4. 线程复用带来的性能提升:线程的创建和销毁过程涉及到操作系统调用和资源分配等操作,而线程的复用可以避免这些开销。通过将任务分配给已经存在的线程,可以减少线程创建和销毁的时间,从而提高系统的性能和效率。

总的来说,线程池的设计可以更好地管理和利用线程资源,通过重用线程、限制线程数量和减少线程创建销毁的开销等方式,提高系统的性能和效率。这使得从线程池中获取线程比每次都创建新线程更高效。


还是举一个现实生活中的例子来形象生动的说明一下:
餐厅的服务员队伍管理。假设某个餐厅有一个固定大小的服务员队伍,每个服务员负责为客人提供餐食和饮料等服务。在餐厅的繁忙时段,可能有很多客人同时到来,而服务员的数量是有限的。
如果餐厅采用每次来客都新招募一名服务员的方式,那么会面临以下问题:

  1. 资源浪费:频繁招募和解雇服务员会浪费大量的时间和成本,而这些服务员在非繁忙时段可能没有工作可做。
  2. 效率低下:每次来客都需要等待新的服务员加入,这会延长客人等待的时间,降低服务的效率。

相反,如果餐厅采用线程池的方式管理服务员队伍,可以获得更高的效率和资源利用率:

  1. 服务员重用:服务员可以被重复利用,不需要每次都新招募。当有客人到来时,线程池中的服务员可以立即提供服务,而不需要等待新服务员的加入。
  2. 控制队伍规模:餐厅可以根据需要设置线程池的大小,确保队伍的规模适应客人的数量和需求。这样可以避免服务员数量过多或过少的问题,提高资源利用率。
  3. 减少服务员调度的开销:线程池管理服务员的生命周期,避免频繁的招募和解雇。这样可以减少服务员调度的开销,提高服务的效率和响应速度。

通过线程池管理服务员队伍,餐厅可以更好地利用服务员资源,提高服务的效率和质量,同时降低成本和资源浪费。这种方式类似于线程池在计算机系统中管理线程资源的方式,通过重用线程、控制线程数量和减少线程创建销毁的开销,提高系统的性能和效率。


除此之外,从线程池拿线程,是纯粹的用户态操作,而从系统创建线程,则会涉及到用户态和内核态之间的切换,真正的创建是要在内核态完成的。那么这个时候可能就有人不理解用户态和内核态到底是个什么玩意,下面我们就来解释解释这两个词什么意思:
我们可以这样认为,:
一个操作系统 = 内核 + 配套的应用程序
内核就是操作系统最核心的功能模块集合,包括但不限于硬件管理、各种驱动、进程管理、内存管理、文件系统。内核需要给上层应用程序提供支持,比如说我们要打印一个“hello”,即 println(“hello”),此时应用程序就会调用系统内核,告诉内核我要进行一个打印字符串的操作,内核再通过驱动程序操作显示器,完成上述功能。
但是呢,应用程序,同一时刻很可能很多,但是内核就一个,那么内核要给这么多应用程序提供服务,有时候就不一定会那么及时。
我们借助画图来形象的理解:
在这里插入图片描述

这里的大厅就相当于用户态,柜台就相当于内核态。
此时滑稽老哥想办个银行卡,但是忘记带身份证复印件,此时有两种选择:

  1. 自己去大厅复印机复印一份,再办卡
  2. 工作人员用柜台里的复印机给你复印
    第一种情况下,自己立即去复印,立即就回来了,不耽误什么时间。但是第二种情况下,工作人员可能还会干点别的事情,确实能复印,但是复印的及不及时就不一定了。

由此我们就可以得出结论:

  1. 用户态操作,时间是可控的
  2. 内核态操作,时间就不太可控了

对于用户态和内核态,我们有这些了解就够了。

3. 详细介绍

回到正题,线程池,java 标准库提供了现成的线程池,我们看看代码怎么写:
创建线程池,有好几种方式,我们简单说一下两种:

ExecutorService pool = Executors.newFixedThreadPool(10);

Executors.newCachedThreadPool();

上面那一行构造一个固定 10 线程的线程池,下面那一行不会设定固定值,按需创建,用完之后也不会立即销毁,留着以备后用。

我们以第一种为例:
在这里插入图片描述
这里的 ExecutorService 翻译成中文为 “执行服务”,我们可以发现,我们并没有直接 new ExecutorService 对象,而是通过调用 Exectors 类中的静态方法,完成对象构造,这其实是一种工厂模式。工厂模式我们这片文章就不介绍了。我们直接点进 newFixedThreadPool 的源码:
在这里插入图片描述
我们发现其实这个方法返回的是 new ThreadPoolExecutor,也就是说这个方法其实是对 ThreadPoolExecutor(原装的线程池) 这个类的进一步封装,我们借助官方文档来简单学习一下 ThreadPoolExecutor,我们在四个构造方法中,挑选参数最多的那个进行学习:
在这里插入图片描述
我们重点理解这几个参数的含义。首先理解这两个:

  1. int corePoolSize 核心线程数
  2. int maximumPoolSize 最大线程数
    我们举个像是生活中的例子来形象生动的进行理解:假设有一家公司(这个公司就相当于线程池),这个公司能够运转考得就是员工,我们粗略的把员工分为两类,一类是正式员工,一类是临时工/实习生。正式员工的数量就相当于是核心线程数,而正式员工的数量加上临时工/实习生的数量就相当于最大线程数。正式员工是签了劳动合同的,不能随便辞退,而实习生不签劳动合同,只是签了实习合同,辞退起来就很容易。一个公司,有时候比较忙,有的时候又比较空闲。那么在比较忙的时候就可以多招一些实习生来增加生产力,到了不忙的时候,大家都比较空闲,就可以把实习生裁掉。这样就可以保证忙的时候生产力的充裕的,闲的时候也不会浪费资源。
    回到线程池上面,如果当前任务比较多,线程池就会多创建一些“临时线程”(总数不会超过最大线程数),如果任务少了,比较空闲了,线程池就会把多出来的临时工线程销毁掉,但是正式员工(所谓的核心线程)是会保留的。
    我们再理解下一组参数:
  3. long keepAliveTime 实习生线程保持存活的时间
  4. TimeUnit unit
    当任务较少,整体较空闲的时候,实习生不是立即被辞退,而是有一个时间段,keepAliveTime 相当于描述了实习生允许的最大摸鱼时间(空闲时间),超出这个时间才会被辞退。而 TimeUnit unit 用于指定线程池中的时间单位,主要用于设置keepAliveTime参数的单位。TimeUnit是一个枚举类型,它定义了几个常用的时间单位,包括NANOSECONDS(纳秒)、MICROSECONDS(微秒)、MILLISECONDS(毫秒)、SECONDS(秒)、MINUTES(分钟)、HOURS(小时)和DAYS(天)。通过指定合适的TimeUnit单位,我们可以更方便地控制线程池中空闲线程的保留时间。
    下一个参数:
  5. BlockingQueue workQueue:线程池里要管理很多任务,这些任务也是通过阻塞队列来组织的。这就相当于程序员可以手动的指定一个阻塞队列给线程池,此时就可以很方便的控制/获取队列中的信息。
  6. ThreadFactory threadFactory:工厂类,线程工厂,创建线程的辅助的类,具体怎么回事我们不去考虑。
    下面我们重点关注一下最后一个参数:
  7. RejectedExecutionHandler handler:线程池的拒绝策略。就是说如果线程池池子满了,继续往池子里添加任务,如何进行拒绝。

经典面试题之线程池的拒绝策略

下图是标准库提供的四种拒绝策略:
在这里插入图片描述
第一种策略 AbortPolicy,意思就如果池子满了的情况下继续添加任务,就会直接抛异常。
第二种策略 CallerRunsPolicy,添加的线程机子负责执行这个任务。比如说有一天老板给员工派任务,员工说:“我才不去,我的任务已经满了,你自己去”。这个任务就由老板来完成。
第三种策略 DiscardOldestPolicy,丢弃最老的任务(所谓的最老的任务就是最先进入阻塞队列的任务)。
第四种策略 DiscardPolicy,丢弃最新的任务,也就是继续执行老的任务,这个新的任务就丢弃了,不会添加到阻塞队列中。
既然我们知道线程池中的任务是添加在阻塞队列中的,根据我们学习的阻塞队列的知识,那么在队列满了后,添加任务是要阻塞的,那为什么要设置这四种拒绝策略呢。下面我们就来说明一下这其中的原因:
当线程池已满且无法继续处理新的任务时,使用阻塞的方式将会导致调用者线程被阻塞,直到线程池中有可用的线程来执行新任务。这种方式可能会引发以下几个问题:

  1. 资源耗尽:如果线程池中的所有线程都被阻塞,而新任务不断地被提交,那么线程池可能会消耗完所有可用的系统资源,导致系统性能下降或崩溃。
  2. 死锁:如果调用者线程被阻塞,而该线程在等待线程池执行的结果,而线程池中的线程又在等待调用者线程的结果,那么可能会导致死锁情况的发生。
  3. 响应性问题:如果调用者线程被阻塞,那么调用者可能无法及时得到响应或处理其他的任务,影响整个系统的性能和响应性能。

所以说通过设置拒绝策略,线程池可以在无法接受新任务时采取特定的处理方式,例如丢弃任务、丢弃最旧的任务或抛出异常等。这样可以更加灵活地控制线程池的行为,避免可能的资源耗尽和性能问题。同时,调用者可以根据拒绝策略的结果来做进一步的处理,而不会被阻塞在等待线程池的执行结果上。
线程池中,不希望依赖 满了 阻塞,主要利用 空了 阻塞。


下面我们拉回来,回到线程池使用的代码上:

public class ThreadTest8 {
    public static void main(String[] args) {

        ExecutorService pool = Executors.newFixedThreadPool(10);

        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程池,启动!");
            }
        });
    }
}

这里的 submit 方法就是给线程池添加任务,就是把任务放到上面所说的阻塞队列中。

4. 手动模拟实现

4.1 代码实现

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

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

    public MyThreadPool(int n) {
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                try {
                    while (true){
                        Runnable runnable = queue.take();
                        runnable.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });

            t.start();
        }
    }
}

public class ThreadTest9 {

    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            int num = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello " + num);
                }
            });
        }
    }

}

运行程序:
在这里插入图片描述
由于线程调度的随机性,数字不是按顺序打印。

4.2 可能出现的疑惑

此时可能会有人有一些疑惑:

疑惑一:

在这里插入图片描述
我们在写代码的时候发现,在输出语句中直接用 i 会报错,只能在循环内部定义一个新的变量,把 i 的值赋给他才能用,原因就是变量捕获,关于变量捕获,我们放到最后讲解。

疑惑二

当前代码中,我们搞了个10个线程的线程池,那么在实际开发中,一个线程池的线程数设置成几比较合适呢?这个时候我们需要通过一定的测试来决定,并没有固定的最优解。原因是不同的程序,线程做的活不一样:

  1. cpu 密集型任务,主要做一些计算工作,要在 cpu 上运行
  2. IO 密集型任务,主要是等待 IO 操作(等待读写硬盘、读写网卡),不咋吃 cpu。
    极端情况下,如果线程全是使用 cpu,那么线程数就不应该超过 cpu 核心数(逻辑核心数,不是物理核心数,比如 6核12线程,这里指的是12不是6);如果线程全是使用 IO ,那么线程数就可以设置很多,可以超过核心数。
    显然现实生活中很少出现这种极端情况,所以说要通过测试的方式来确定。

5. 变量捕获介绍

在Lambda表达式中,可以捕获两种类型的变量:局部变量和实例变量。不过,对于局部变量和实例变量的捕获规则有所不同。

  1. 局部变量的捕获:

    • Lambda表达式中的局部变量必须是final或者是effectively final(即在初始化后不再修改)。
    • 如果Lambda表达式中捕获了局部变量,那么它在Lambda表达式中是只读的,无法对其进行修改。
    • Java 8之后,可以省略final关键字,编译器会自动将局部变量视为final

    示例代码:

    int num = 10; // 局部变量
    Runnable r = () -> {
        System.out.println(num); // 访问捕获的局部变量
    };
    
  2. 实例变量的捕获:

    • Lambda表达式可以捕获实例变量,无论是final还是非final的实例变量。
    • Lambda表达式可以访问和修改捕获的实例变量。

    示例代码:

    public class Example {
        private int count; // 实例变量
    
        public void doSomething() {
            Runnable r = () -> {
                count++; // 修改捕获的实例变量
                System.out.println(count); // 访问捕获的实例变量
            };
            r.run();
        }
    }
    

需要注意的是,Lambda表达式中的变量捕获规则是在Java 8引入Lambda表达式时引入的。在Java 8之前,匿名内部类中捕获变量的规则是不同的。此外,对于Lambda表达式中的捕获变量,需要特别注意在多线程环境下的线程安全性。

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不想菜的鸟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值