线程池
池(pool)是一个非常重要的思想方法
内存池
进程池
链接池
常量池......这些"池"概念上都是一样的
如果我们需要频繁的创建销毁线程,此时创建销毁线程的成本就不能忽视了,因此我们可以使用线程池,线程池就是提前创建好一波线程,后续需要使用线程就从池子里拿就行,当线程不再使用,就放回池子里,把创建销毁线程变成从池子里拿和归还线程
但是为啥从池子里取会比创建线程更快更高效呢?
因为如果是从系统这里创建线程,就需要调用系统的API,进一步又操作系统内核完成线程创建的过程(内核是给所有进程提供服务的,谁知道啥时候轮到你,这是不可控的), 但是如果是从线程池这里获取线程,上述的内核中进行的操作,都提前做好了,现在的取线程的过程,纯粹的用户代码完成的(纯用户态),这是用户自己控制的,是可控的
Java的标准库中,也提供了现成的线程池
等号左边是线程池对象,等号右边是创建线程池对象的过程,其中Executors称为"工厂类",而newFixedThreadPool是"工厂方法",这个4是线程数量
这里我们就涉及到一个模式,工厂模式,工厂,顾名思义,就是生产对象的意思,我们一般创建对象都是通过new并且通过构造方法,但是构造方法存在重大缺陷,这个缺陷就是:构造方法的名字固定就是类名,但是有的类,需要有多种不同的构造方式,但是构造方法名字又固定,就只能使用方法重载的方式来实现了,重载的参数的个数和类型需要有差别.此时我们就可以使用工厂模式解决上述问题
举个缺陷的例子,这是一个点,描述一个点可以通过坐标或者极坐标
class Point{ //两种构造方法
publicPoint(double x,double y){}; //通过坐标的构造方法
publicPoint(double x,double y){}; //通过极坐标的构造方法
}
这两种构造方式,参数的个数和类型是一样的,无法构成重载
我们可以使用工厂模式解决上述问题,不用构造方法了,使用普通的方法来构造对象,这样的方法名字就可以是任意的了,然后普通方法内部我们再来new对象,然后由于普通方法目的是为了创建出对象来,这样的方法一般得是静态的
class Point{
public static makePointXY(double x,double y){};
public static makePointRA(double x,double y){};
}
此时我们想new一个对象就可以:Point p = Point makePointXY(10,20);
上面的代码我们就称之为工厂模式,这两个方法我们称之为工厂方法
下面我们看看几个创建线程池的方法
ExecutorService service = Executors.newFixedThreadPool(4);
Executors.newCachedThreadPool();//创建出一个线程数目动态变化的线程池
Executors.newSingleThreadExecutor();//包含单个线程,比原生的线程API要更简单一点
Executors.newScheduledThreadPool();//类似于定时器的效果,添加一些任务,任务都在后续的某个时刻再执行,被执行的时候,不止一个扫描线程来执行任务,可能有多个线程来执行任务,类似于一个线程分几个任务
线程池对象搞好了之后,使用submit方法,就可以把多个任务添加到线程池中,看代码
public class Demo23 {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0;i < 1000;i++) {
service.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
,线程池中有四个线程,这四个线程就会合力把我们分配的1000个任务一起完成
除了上述这些线程池之外,标准库还提供了一个接口更丰富的线程池类
ThreadPoolExecutor
上面那四个线程池都是针对ThreadPoolExecutor这个原生的类进行的封装,这四个线程池为了使用方便,已经做了一层封装了
这个原生的线程池提供的接口非常丰富,有很多供我们调整的选项,能更好地满足需求
经典面试题,谈谈Java标准库里的线程池构造方法的参数和含义
ThreadPoolExecutor 里面的线程个数,并非是固定不变的,会根据当前任务的情况动态发生改变
这样既能保证繁忙的时候高效地处理任务,又能保证空闲的时候不会浪费资源
int corePoolSize 核心线程数(意思是线程池中至少要有这么多线程,哪怕没有任务)
int maximumPoolSize 最大线程数(意思是最多不能超过这些线程,哪怕线程池冒烟了)
long KeepAliveTime (时间),TimeUnit unit (时间单位),假如设定是3000ms,那么线程空闲时间超过3000ms就会被销毁
BlockingQueue<Runnable> workQueue 线程池内部有很多任务,这些任务,可以使用阻塞队列来管理
线程池可以内置阻塞队列,也可以手动指定一个
ThreadFactory ThreadFactory 工厂模式,通过这个工厂类来创建线程
RejectedExecutionHandler handler 线程池考察的重点,也叫作拒绝方式/拒绝策略,线程池有一个阻塞队列,当阻塞队列满了以后,继续添加任务,该如何应对
第一个应对方法:ThreadPoolExecutor.AbortPolicy (直接抛出异常,线程池就不干活了)
第二个应对方法:ThreadPoolExecutor.CallerRunsPolicy (谁是添加这个新任务的线程,就谁去执行这个任务,把任务丢回去)
第三个应对方法:ThreadPoolExecutor.DiscardOldestPolicy (丢弃最早的任务,执行新的任务)
第四个应对方法:ThreadPoolExecutor.DiscardPolicy (直接丢弃新的任务)
上面谈到的线程池有两组,一组是封装过的,Executors
一组是原生的,ThreadPoolExecutor
用哪个都可以,主要还是看实际的需求
但是有的公司更建议用原生的,因为更直观,每一个方面怎么设定的在构造方法都有体现
接下来我们要自己实现一个线程池
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;
class MyThreadPool{
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();//用阻塞队列来保存若干个任务
//通过这个方法,把任务添加到线程池中
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
//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 class Demo24 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(4);
for (int i = 0;i < 1000;i++){
pool.submit((new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
}));
}
}
}
运行结果就是打印了1000个hello,这1000个hello都是由线程池这四个线程一起打印出来的,并且是随机的
由于线程的调度是随机的,所以当前给这个线程中插入的任务在执行的时候,分配给各个线程的任务数量也不一定均等,都看缘分
创建线程池的时候,线程个数是咋来的(就是代码中的那个4怎么来的?)
不同的项目中,线程要做的工作,是不一样的
有的线程的工作是"CPU密集型",线程的工作全是运算(大部分工作都是要在CPU上完成的,CPU得给他安排核心去完成工作才可以有进展)如果CPU是N个核心,当线程数量也是N的时候,理想情况就是每个核心上一个线程,如果搞很多线程,那也是在排队等待,不会有新的进展
有的线程的工作是"IO密集型",读写文件,等待用户输入,网络通信(大部分时候都在等,等的过程中没有使用CPU)这样的线程多一些,也不会给CPU造成太大的负担
实际开发中,一个线程往往是一部分工作是CPU密集的,一部分工作是IO密集的,这个线程里有几成是CPU密集的,有几成是IO密集的,这是很难量化的,这里更好的做法是通过实验的方式来找到合适的线程数,通过性能测试,尝试不同的线程数目,尝试过程中,找到性能和系统资源开销,比较均衡的数值
接下来是多进程的进阶
多进程进阶,主要围绕一些多线程相关的面试题展开的
1.常见的锁策略
锁策略就属于是,实现锁的人需要重点去理解的,如果只是单纯使用锁,就不太需要知道
我们需要认识几种常见的锁策略,能够知道概念,并且能阐述清楚
锁策略指的不是一个具体的锁,它是一个抽象的概念,描述的是锁的特性,描述的是"一类锁"
乐观锁VS悲观锁
乐观锁:预测该场景中,不太会出现锁冲突的情况(后续做的工作更少)
悲观锁:预测该场景,非常容易出现锁冲突(后续做的工作会更多)
锁冲突:两个线程尝试获取一把锁,一个线程获取成功,另一个线程阻塞等待
锁冲突的概率大小,会对后续的工作有一定影响,举个例子就是,假如你喜欢校花/校草,那么如果你想追求成功,你遇到情敌的概率就会非常大,那你想要追上他,就需要做更多的工作
重量级锁VS轻量级锁
重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源多)
一个悲观锁做的工作更多,很可能是重量级锁(不绝对)
轻量级锁:加锁的开销比较小(花的时间少,占用系统资源少)
一个乐观锁做的工作更少,很可能是轻量级锁(不绝对)
悲观乐观,是在加锁之前,对锁冲突的预测,决定工作的多少
重量轻量,是在加锁之后,考量实际的锁的开销
正是因为它们概念存在重合,针对一个具体的锁,我们看待的角度不同,对其称呼也会不同,有人叫他乐观锁,也会有人叫他轻量级锁
自旋锁(Spin Lock)VS挂起等待锁
自旋锁:是轻量级锁的一种典型实现
在用户态下,通过自旋的方式(while循环),实现类似于加锁的效果,举个例子就是,追一个人的时候,发现他有对象了,但是还是坚持跟他发早安晚安联络感情,这样就可以等他分手的时候第一时间上位
这种锁,会消耗一定的CPU资源,但也可以做到最快的速度拿到锁
挂起等待锁:是重量级锁的一种典型实现
通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程挂起(阻塞等待),举个例子,就是追一个人的时候,发现他有对象了,就不再联络了,偶然间又听说他单身了,再去联络这个人,这样做不能第一时间发现他分手,有可能等你发现,他已经换了好几个对象了
这种方式,消耗的CPU资源是更少的,也就无法第一时间拿到锁
读写锁VS互斥锁
读写锁:把读操作加锁和写操作加锁分开了
一个事实:多线程同时去读同一个变量,不涉及线程安全问题
如果两个线程,一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争,此时多线程并发执行的效率更高,两个读操作并不会有线程安全问题,所以大家不用争,这样效率就会提高
如果两个线程,一个线程写加锁,另一个线程也是写加锁,会产生锁竞争
如果两个线程,一个线程读\写加锁,另一个线程是读加锁,会产生锁竞争
Java标准库里,也提供了现成的读写锁
公平锁VS非公平锁
公平锁是遵守先来后到的锁,比如说追校花,校花有对象,大家按照追的时间长短排队,等到校花分手.时间最长的人就可以先和校花在一起,这就是公平锁
非公平锁就是不管先来还是后到,大家公平竞争,这个看起来是概率均等,实际上是不公平的,每个线程的阻塞时间是不一样的
操作系统自带的锁(pthread_mutex)属于是非公平锁
要想实现公平锁,就需要一些额外的数据结构来支持,比如需要有办法记录每个线程的阻塞时间
可重入锁VS不可重入锁
如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁;不会出现死锁,就是可重入锁
什么是死锁?我们放在下一篇博客