• 2023.10.23 关于 线程池 详解


    目录

    引言

     字符串常量池

    数据库连接池 

    线程池 

    基本原理

    线程池的主要参数 

    ThreadPoolExecutor 的构造方法

    常见线程池

    newCachedThreadPool()

    newFixedThreadPool()

    newSingleThreadExecutor()

    newScheduledThreadPool()

    标准库线程池的使用 

    理解工厂模式

    引入工厂模式原因

    线程池具体使用

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

    自己实现一个简单线程池


    引言

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

     字符串常量池

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

    实例理解

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

    数据库连接池 

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

    实例理解

    1. // 获取数据库连接池 初始化数据库连接池
    2. DataSource dataSource = new MysqlDataSource();
    3. ((MysqlDataSource)dataSource).setURL("jdbc:mysql://127.0.0.1:3306/java105?characterEncoding=utf8&useSSL=false"); //告诉数据库在哪
    4. ((MysqlDataSource)dataSource).setUser("root"); //用户名
    5. ((MysqlDataSource)dataSource).setPassword(""); //安装数据库时设置的密码
    6. // 从连接池获取数据库连接
    7. Connection connection = dataSource.getConnection();
    8. // 使用数据库连接进行数据库操作
    9. // 将连接归还给连接池
    10. connection.close();

    线程池 

    基本原理

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

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

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

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

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

    线程池的主要参数 

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

    ThreadPoolExecutor 的构造方法

    1. ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
    2. TimeUnit unit, BlockingQueue workQueue,
    3. 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 的过程中需要调用构造方法,如果希望能够提供多种构造实例的方法,就需要重载构造方法来实现不同版本的实例创建,但是重载要求方法名相同,但 参数个数 或 类型不同,所以就带来了一定的限制
    • 正构造方法存在一定的局限性,所以为了绕过局限,就引入了工厂模式

    实例理解

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

    线程池具体使用

    1. import java.util.concurrent.ExecutorService;
    2. import java.util.concurrent.Executors;
    3. //使用一下标准库的线程池
    4. public class ThreadDemo26 {
    5. public static void main(String[] args) {
    6. ExecutorService pool = Executors.newFixedThreadPool(10);
    7. for (int i = 1; i <= 11; i++) {
    8. int n = i;
    9. pool.submit(new Runnable() {
    10. @Override
    11. public void run() {
    12. System.out.println(Thread.currentThread().getName()+ "执行了第" + n + "次 hello");
    13. }
    14. });
    15. }
    16. }
    17. }

    运行结果:

    • 我们向线程池中提交了 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 方法,可以将具体的任务逻辑封装起来,供线程调度和执行
    1. import java.util.concurrent.BlockingQueue;
    2. import java.util.concurrent.LinkedBlockingQueue;
    3. class MyThreadPool {
    4. // 此处不涉及到 时间,此处只有任务,直接使用 Runnable 即可
    5. private BlockingQueue queue = new LinkedBlockingQueue<>();
    6. // n表示线程的数量
    7. public MyThreadPool(int n) {
    8. // 这里创建出线程
    9. for (int i = 0; i < n; i++) {
    10. Thread t = new Thread(() -> {
    11. while (true) {
    12. try {
    13. Runnable runnable = queue.take();
    14. runnable.run();
    15. } catch (InterruptedException e) {
    16. e.printStackTrace();
    17. }
    18. }
    19. });
    20. t.start();
    21. }
    22. }
    23. // 注册任务给线程池
    24. public void submit(Runnable runnable) throws InterruptedException {
    25. queue.put(runnable);
    26. }
    27. }
    28. public class ThreadDemo28 {
    29. public static void main(String[] args) throws InterruptedException {
    30. MyThreadPool myThreadPool = new MyThreadPool(10);
    31. for (int i = 1; i <= 11; i++) {
    32. int n = i;
    33. myThreadPool.submit(new Runnable() {
    34. @Override
    35. public void run() {
    36. System.out.println(Thread.currentThread().getName()+ "执行了第" + n + "次 hello");
    37. }
    38. });
    39. }
    40. }
    41. }

    运行结果:

  • 相关阅读:
    K8S日志收集
    python安全工具开发笔记(一)——python正则表达式
    基于51单片机驱动A4988实现步进电机逆时针转动
    MYSQL介绍——数据库约束与范式
    路由器ip地址设置
    做着做着感觉自媒体做不下去了?
    【论文阅读】Twin Neural Network Regression
    计算机操作系统:实验2 【银行家算法】
    UNet网络制作
    【STM32】入门(零):keil安装、科学使用、芯片包安装
  • 原文地址:https://blog.csdn.net/weixin_63888301/article/details/133996898