2023.10.23 关于 线程池 详解

目录

引言

 字符串常量池

数据库连接池 

线程池 

基本原理

线程池的主要参数 

ThreadPoolExecutor 的构造方法

常见线程池

newCachedThreadPool()

newFixedThreadPool()

newSingleThreadExecutor()

newScheduledThreadPool()

标准库线程池的使用 

理解工厂模式

引入工厂模式原因

线程池具体使用

插入知识点(重载 和 重写 的区别)

自己实现一个简单线程池


引言

  • 线程 存在的原因:因为使用 进程 来实现并发编程,太重量级了
  • 所以便引入了线程,线程 也是叫做 " 轻量级进程 "
  • 创建 线程 比创建 进程 更高效
  • 销毁 线程 比销毁 进程 更高效
  • 调度 线程 比调度 进程 更高效
  • 从而使用多线程就可以在很多时候来代替进程来实现并发编程了
  • 随着并发程度的提高,对于性能要求标准的提高
  • 在当我们需要频繁创建销毁 线程 的时候,其开销还是比较大
  • 从而为了减小这里频繁创建销毁 线程 的开销,在 Java 中我们便引入了线程池

 字符串常量池

  • 字符串常量池 用于存储字符串常量的一块内存区域
  • 在 Java 中,字符串常量池是为了节省内存而设计的,可以避免重复创建相同内容的字符串对象
  • 当我们使用字符串字面量创建字符串对象时,如果字符串常量池中已经存在相同内容的字符串,则直接返回常量池中的对象引用,而不会创建新的对象
  • 这样可以节省内存,并提高字符串比较效率

实例理解

String str1 = "Hello"; // 字符串常量池中创建一个"Hello"对象
String str2 = "Hello"; // 直接使用常量池中的"Hello"对象
String str3 = new String("Hello"); // 创建一个新的字符串对象
  • 在上述实例中,str1 和 str2 引用的是同一个字符串对象,因为它们的内容相同且都是字符串常量
  • 而 str3 则创建了一个新的字符串对象,因为使用了 new 关键字

数据库连接池 

  • 数据库连接池 是一种关联数据库连接的技术
  • 在数据库操作中,建立和关闭数据库连接是一项开销较大的操作,频繁地创建和销毁连接会造成性能下降
  • 数据库连接池通过预先创建一定数量的数据库连接,并将这些连接保存在池中,共应用程序使用
  • 应用程序需要数据库连接时,可以从连接池中获取一个空闲连接,使用完毕后归还给连接池,而不是每次都创建和关闭连接
  • 数据库连接池可以提高数据库访问的性能和效率,减少连接的创建和销毁开销,并可以设置最大连接数、超时时间 等

实例理解

// 获取数据库连接池   初始化数据库连接池
DataSource dataSource = new MysqlDataSource();
((MysqlDataSource)dataSource).setURL("jdbc:mysql://127.0.0.1:3306/java105?characterEncoding=utf8&useSSL=false"); //告诉数据库在哪
((MysqlDataSource)dataSource).setUser("root"); //用户名
((MysqlDataSource)dataSource).setPassword(""); //安装数据库时设置的密码

// 从连接池获取数据库连接
Connection connection = dataSource.getConnection();

// 使用数据库连接进行数据库操作

// 将连接归还给连接池
connection.close();

线程池 

基本原理

  • 事先把需要使用的线程创建好,放到池中,后面需要使用的时候,就不用再创建线程了,而是直接从池里获取现成的线程供使用,即使该线程完成了任务,也不销毁线程,而是继续呆在线程池中,准备迎接下一个任务

为什么从池子中 取放线程,比创建销毁线程快呢?
  • 创建线程和销毁线程 是交由操作系统内核完成
  • 从池子里获取和还给池子自己用户代码就能实现的,不必交给内核操作

创建线程的过程大致如下:

  • 引用程序发起创建线程的行为,
  • 内核接到指令,在内核中完成 PCB 的创建,
  • 再把 PCB 加入调度队列中,最后返回给应用程序

  • 用户态执行的是程序员自己写的代码,想干啥、怎么干,都是由程序员自主决定
  • 但是有些操作,必须在内核态中进行完成,内核态进行的操作都是在操作系统中完成的,内核会给程序提供一些 api,也称为系统调用
  • 系统调用里面的内容是直接和内核的代码相关的,这一部分工作不受程序员自身控制,都是由内核完成
  • 内核不是只给你一个应用程序服务,而是给所有的程序都要提供服务,在使用系统调用,执行内核代码的时候,无法确定内核都要做那些工作,哪些工作先做,哪些工作后做,这个整体过程是不可控的
  • 所以相比于在内核中创建出一个线程,使用线程池直接在用户态获取线程的行为是可控的,从池子里取拿线程,完成的十分干净利落

线程池的主要参数 

  • 线程池的本体叫 ThreadPoolExecutor,通过调用 ThreadPoolExecutor 的构造方法,并设置相对应的参数,来创建出一个相对应的线程池实例

ThreadPoolExecutor 的构造方法

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, 
                   TimeUnit unit, BlockingQueue<Runnable> workQueue, 
                   ThreadFactory threadFactory, RejectedExecutionHandler handler)
参数解释
corePoolSize核心线程数
maximumPoolSize最大线程数
keepAliveTime临时线程 最大空闲时间
unitkeepAliveTime 的 时间单位(s,ms,分钟......)
workQueue任务队列,用于传输和保存等待执行任务的 阻塞队列
threadFactory线程工厂,用于创建新线程,工厂对象负责创建线程,程序员可以手动指定线程创建策略
handler线程池拒绝策略,描述了当线程池任务队列满了,继续添加新任务会有啥样的行为

ThreadPoolExecutor 相当于把里面的线程分成两类

  • 一类为正式员工 ( 核心线程 )
  • 一类为实习生( 临时线程 )
  • 允许正式员工摸鱼,不允许实习生摸鱼
  • 如果实习生摸鱼模的太久了,就会被开除,也就是当临时线程达到了 keepAliveTime 参数规定的最大 空闲时间,该临时线程就会被销毁
  • 如果任务多,显然需要更多的人手(更多的线程)
  • 此时多搞一些线程,成本也是值得的
  • 但是一个程序任务不一定始终都很多,有时候多,有时候少
  • 如果现在任务少了,此时线程还那么多,就非常不合适了,就需要对现有的线程进行一定的淘汰
  • 整体的策略便是 正式员工保底,临时工动态调节

在实际的开发过程中,线程池的线程数,设定成多少合适呢?

  • 在具体面试中遇到该问题,只要回答了数字,那么一定回答错误!
  • 因为不同的程序特点不同,此时要设置的线程数也是不同的
  • 考虑两个极端情况
  • CPU 密集型: 每个线程要执行的任务都是狂转 CPU(进行一系列算术运算),此时线程池数,最多也不应该超过 CPU 核数此时如果你设置的再大,也没有意义,因为 CPU 已经被占满了
  • IO 密集型:每个线程干的工作就是等待 IO(读写硬盘、读写网卡、等待用户输入 等),不吃 CPU 资源,此时这样的线程处于阻塞状态,不参与 CPU 调度这个时候多搞一些线程都无所谓,因为不再受限于 CPU 核数了
  • 然而,在实际开发中并没有程序符合这两种理想模型
  • 真正的程序,往往一部分要吃 CPU,一部分要等待 IO
  • 具体这个程序 几成工作量是吃 CPU 的,几成工作量是等待 IO,这是不确定的
  • 实践中确定线程数量很简单,通过 测试 和 实验 的方式,分别记录 不同线程数 对程序一些核心性能指标 和 系统负载情况,最后选择一个合适的线程数

注意:

  • 现代的 CPU 常见 8 核 16 线程 的字样
  • 实际上 8核代表 8个物理核心,每个物理核心有两个逻辑核心
  • 每个逻辑核心同一时刻只能处理一个线程
  • 一般对于程序员来说,谈到 CPU 核心数,指的就是逻辑核心

  • 以上为标准库提供的四个拒绝策略
  • 面试的时候最好举例说明

常见线程池

newCachedThreadPool()

  • 这里的线程数量是动态变化的,如果任务多了,就多搞几个线程,如果任务少了,就少搞几个线程

newFixedThreadPool()

  • 该线程池最大的特点是它的核心线程数和最大线程数是一致的,并且是一个固定线程的线程池

newSingleThreadExecutor()

  • 该线程池中仅有一个线程

newScheduledThreadPool()

  • 类似于定时器,也是让任务延时执行,只不过执行的时候不是由扫描线程自己执行了,而是由单独的线程池来执行

既然 new Thread() 和 newSingleThreadExecutor() 都是创建一个线程处理,为什么还需要存在单个线程的线程池呢?

  • 通过 new Thread() 方式创建出来的线程是一次性的,任务执行完毕后,线程就会被销毁,如果要处理多个任务,每个任务都需要创建一个新的线程,这样频繁地创建和销毁线程会带来一定的开销,且需要手动管理任务的调度和线程间的通信
  • 通过 newSingleThreadExecutor() 的方式,先初始化好一个线程放在池子中,该线程可以复用处理多个任务,避免了线程频繁创建和销毁,提高了效率,且自带阻塞队列用来顺序执行任务

标准库线程池的使用 

理解工厂模式

  • 在 Java 中,线程池的本体叫 ThreadPoolExecutor,他的构造方法写起来十分麻烦,为了简化构造方法,标准库提供了一系列 "工厂方法",以便其简化使用
  • 简单来说就是 使用 普通方法 来代替 构造方法,创建对象

引入工厂模式原因

  • new 的过程中需要调用构造方法,如果希望能够提供多种构造实例的方法,就需要重载构造方法来实现不同版本的实例创建,但是重载要求方法名相同,但 参数个数 或 类型不同,所以就带来了一定的限制
  • 正构造方法存在一定的局限性,所以为了绕过局限,就引入了工厂模式

实例理解

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

//使用一下标准库的线程池
public class ThreadDemo26 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
    }
}
  • 这个操作,即使用某个类的某个静态方法,直接构造出一个对象来,相当于把 new 操作,给隐藏到这样的方法后面了
  • 像这样的方法,就称为 "工厂方法"
  • 提供这个工厂方法的类,也就称为 "工厂类"
  • 此处这个代码就是用了 "工厂模式" 这种 设计模式

线程池具体使用

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

//使用一下标准库的线程池
public class ThreadDemo26 {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        for (int i = 1; i <= 11; i++) {
            int n = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+ "执行了第" + n + "次 hello");
                }
            });
        }
    }
}

运行结果:

  • 我们向线程池中提交了 11个打印操作 的任务
  • 此时 观察结果可以发现,线程池中的空闲线程会主动来完成这些任务
  • 这 11个打印操作 任务放入了线程池中,线程池中的空闲线程 均会去拿 打印操作 任务,并成功打印 hello,从而相当于这 11个打印操作任务 被空闲的10个线程 分别执行完成
  • 当然 在每一个线程都执行完任务之后,还会立即再取一下个任务,由于这里都是执行 打印 hello 的操作,因此每个线程做的任务数量就差不多
  • 注意这里图中的 4号线程 执行了两次 打印操作,意味者 4号线程先比其他线程先执行完打印操作,然后再拿到了下一次的打印操作的任务并执行
  • 进一步的可以认为,这 11个任务,就相当于在一个队列中排队,这 10个线程依次来取队列中的任务,取一个就执行一个,执行完了之后再执行下一个,当然由于 CPU 调度的随机性,并不一定是先取到任务的线程,必会先执行完任务,如上图运行结果所示

注意:

  • 运行程序之后发现,main 线程结束了,但整个进程没结束
  • 因为线程池中的线程都是 前台线程,此时会阻止进程结束

上图所示,为什么不能直接使用 i ,而需将 i 的值赋给 n ,再使用 n 呢?

  • 此处涉及到 变量捕获 的语法规则
  • 很明显,此处的 run 方法属于 Runnable 
  • 这个方法的执行实际,不是立刻马上,而是在未来的某个节点,即后续在线程池的队列中,排到它了,便就让对应的线程去执行它
  • 但是 此处的变量 i ,是在主线程里的局部变量,即在主线程的栈上,随着主线程这里的代码块执行结束就销毁了
  • 换句话说,很可能主线程这里的 for 执行完了,当前 run 的任务在线程池里还没排到呢,此时 i 就已经要销毁了
  • 所以为了避免作用域的差异,导致后续执行 run 的时候 i 已经销毁,于是就有了 变量捕获,也就是让 run 方法把刚才主线程的 i 给往当前 run 的栈上拷贝一份
  • 也就是在定义 run 的时候,把 i 当前的值记住,后续执行 run 的时候,就创建一个也叫做 i 的局部变量,并且把这个值赋值过去
  • 在 Java 中,即 JDK 1.8 之后,对于 变量捕获 的语法规则,其要求为:只要代码中没有修改这个变量,该变量便可以被捕获
  • 在上述代码中,我们尝试捕获 i ,但是发现 i 在 for 循环中,i 的值不断地在改变,所以 i 自然不能被 捕获
  • 所以 我们便创建了一个 变量 n,因为该变量 n 没人进行修改,即仅进行了初始的赋值,后续未被修改,所以这里的 变量 n,能被正常捕获

插入知识点(重载 和 重写 的区别)

重载:

  • 要求在同一个作用域下
  • 如这两个方法在同一个类里,可以构成重载
  • 分别在父类子类里,也可以构成重载
  • 即按照要求:方法名相同,参数个数 或 类型不同,便能构成重载

重写:

  • 在 Java 中方法重写是和父类子类相关的
  • 本质上就是用一个新的方法,来代替旧的方法
  • 所以就得要求 新方法 和 旧方法,名字 和 参数 都得一模一样

自己实现一个简单线程池

  • 以下是实现一个固定线程数的线程池(类似简单版 newFixedThreadPool)

一个线程池中有两个主要部分

  • 用阻塞队列来保存任务
  • 若干个工作线程

理解 Runnable

  • 记住 Runnable 是 Java 中的一个接口,用于定义可以在线程中执行的任务
  • 它是线程线程执行的抽象,通过实现 Runnable 接口并实现其中的 run 方法,可以将具体的任务逻辑封装起来,供线程调度和执行
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class MyThreadPool {
//    此处不涉及到 时间,此处只有任务,直接使用 Runnable 即可
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

//    n表示线程的数量
    public MyThreadPool(int n) {
//        这里创建出线程
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                while (true) {
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
        }
    }

//    注册任务给线程池
    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }
}

public class ThreadDemo28 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool = new MyThreadPool(10);
        for (int i = 1; i <= 11; i++) {
            int n = i;
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+ "执行了第" + n + "次 hello");
                }
            });
        }
    }
}

运行结果:

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

茂大师

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

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

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

打赏作者

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

抵扣说明:

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

余额充值