目录
引言
字符串常量池
数据库连接池
线程池
基本原理
线程池的主要参数
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
workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
参数 解释 corePoolSize 核心线程数 maximumPoolSize 最大线程数 keepAliveTime 临时线程 最大空闲时间 unit keepAliveTime 的 时间单位(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
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"); } }); } } } 运行结果: