目录
引言
- 线程 存在的原因:因为使用 进程 来实现并发编程,太重量级了
- 所以便引入了线程,线程 也是叫做 " 轻量级进程 "
- 创建 线程 比创建 进程 更高效
- 销毁 线程 比销毁 进程 更高效
- 调度 线程 比调度 进程 更高效
- 从而使用多线程就可以在很多时候来代替进程来实现并发编程了
- 随着并发程度的提高,对于性能要求标准的提高
- 在当我们需要频繁创建销毁 线程 的时候,其开销还是比较大
- 从而为了减小这里频繁创建销毁 线程 的开销,在 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 临时线程 最大空闲时间 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<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"); } }); } } }
运行结果: