本文主要介绍了线程池的概念以及它的创建,同时包含有关线程池类的常见面试题,以及常见的锁策略。
一.线程池
如果我们需要频繁的创建和销毁线程,此时创建销毁线程的成本,就不能忽视了。
因此就可以使用线程池
提前创建好一波线程,后续需要使用线程,就可以直接从池子里拿一个即可。
本来,需要创建/销毁线程;
现在,从池子里获取现成的线程,并且把线程归还到池子中;
为什么这样更高效?
如果从系统创建,需要调用系统api,进一步的由操作系统内核完成线程的创建过程(内核给所有的进程提供服务)
如果是从线程池这里获得线程,上述的内核中进行的操作,都是提前做好的,现在的取线程的过程,是纯粹的用户代码完成(纯用户态)
1.工厂模式
Java标准库中,提供了现成的线程池。
创建线程池对象的过程,Executors称为 工厂类。
我们把它创建线程的方式叫做:工厂模式。
优点:
使用工厂模式可以解决构造方法的一些缺陷(构造方法名固定,但有的类需要不同的构造方法,又无法使用重载)
而工厂模式使用普通的方法来构造对象,方法名任意。
在普通方法内部,再来new对象。(由于普通的方法目的是为了创建出对象,这样的方法一般为静态。)
2.ThreadPoolExecutor线程池类
除了上述的线程池之外,标准库还提供了一个接口更丰富的线程池类
ThreadPoolExecutor类对上述做了封装。
3.经典面试题
谈谈Java标准库中,线程池类(ThreadPoolExecutor)构造方法的参数和含义。
注意:线程池里面的线程个数,并非是固定不变的,会根据当前任务的情况动态发生变化(自适应 )
参数:
1)Int corePoolSize 核心线程数
至少得有这些线程,哪怕线程池一点任务也没有
2)Int maximumPoolSize 最大线程数
最多不饿能超过这些线程,哪怕你的线程忙的冒烟,也不能比这个数目更多了
做到既能够保证繁忙的时候高效的处理任务,又能保证空闲的时候不会浪费资源。
3)Long keepAliveTime , TimeUnit unit
允许线程空闲的最大时候
超过指定时间就销毁
4)BlockkingQueue workQueue
线程池内部有很多任务,这些任务,
可以使用阻塞队列来管理
5)ThreadFactory threadFactory
通过这个工厂类来创建线程
6)RejectedExecutionHandler handler
(线程池考察的重点,拒绝方式/策略)
丢弃策略:
线程池,有一个阻塞队列列
当阻塞队列满了之后,继续添加任务,该如何应对
4.丢弃策略
1)ThreadPoolExecutor.CallerRunsPolicy
谁是添加这个新任务的线程,谁就去执行这个任务
2)ThreadPoolExecutor.DiscardOldestPolicy
丢弃最早的任务,执行性的任务
3)ThreadPoolExecutor.DiscarPolicy
丢弃新的任务
继续执行之前的任务
总结:
上面谈到的线程池
一组是封装过的 Executors
一组是ThreadPoolExecutor原生的(有的公司推荐这种,阿里使用)
具体使用,主要还是看实际的需求。
二.实现一个自己的线程池
class MyThreadPool{
private BlockingQueue<Runnable> queue=new LinkedBlockingQueue<>();
//通过这个方法,来把这个任务添加到线程池中
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 TestDemo14 {
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(Thread.currentThread().getName()+ "hello");
}
});
}
}
}
三.锁策略
面试题:
常见的锁策略
主要是认识几种常见的锁策略,能够知道概念。
1.乐观锁 悲观锁
不是具体的锁,抽象的概念,描述的是锁的特性
描述的是一类锁
乐观锁:预测该场景中,不太会出现锁冲突的情况
后续做的工作少
悲观锁:预测该场景中,非常容易出现锁冲突
后续做的工作多
2.重量级锁 轻量级锁
重量级锁:加锁的开销是比较大的 (花的时间多,占用系统资源多)
轻量级锁:加锁开销比较小的 (花的时间少,占用系统资源少)
一个悲观锁,很可能是重量级锁(不绝对)
一个乐观锁,很可能是轻量级锁(不绝对)
悲观乐观,是在加锁之前,对锁冲突概率的预测,决定工作的多少
重量轻量,是在加锁之后,考量实际的锁的开销。
3.自旋锁 挂起等待锁
自旋锁:是轻量级锁的一种典型实现
在用户态下,通过自旋的方式(while循环),实现类似于加锁的效果的
这种锁,会消耗一定的cpu资源,但是可以做到最快速度拿到锁
挂起等待锁:是重量级锁的一种典型实现
通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核
对于线程的调度,是冲突的线程出现挂起(阻塞等待)
这种方式,消耗cpu资源更少,也就无法保证第一时间拿到锁
4.读写锁 互斥锁
读写锁:把读操作加锁和写操作加锁分开了
数据库事务 隔离级别(这两个读操作加锁 和 写操作加锁,不太一样)
事务中的读加锁,写加锁要比此处的读写锁,粒度更细,情况分更多。
注意:多线程同时去读同一个变量,不涉及到线程安全问题
如果两个线程,
一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争
一个线程写加锁,另一个线程也是写加锁,会产生锁竞争
一个线程写加锁,另一个线程读加锁,也会产生锁竞争
实际开发中,读操作频率比写操作,高很多。
Java标准库中,也提供了线程的读写锁
5.公平锁 非公平锁
公平锁:遵循先来后到
非公平锁:看起来是概率均等,但是实际上是不公平的(每个线程阻塞时间不一样)
操作自带的锁属于是非公平锁
要想实现公平锁,就需要一些额外的数据结构来支持(比如需要有办法记录每个
线程的阻塞等待时间)
6.可重入锁 不可重入锁
可重入锁:有一个线程,针对同一把锁,没有产生死锁,就是可重入锁;
不可重入锁:有一个线程,针对同一把锁,如果产生了死锁,那就是不可重入锁;
这里的关键在于,两次加锁,都是同一个线程
第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候不应该该加锁
失败,不应该阻塞等待。
如果是不可重入锁,这把锁不会保存是哪个线程对它加的锁,只要它当前处于加锁状态之后,收到了 加锁 这样的请求,就会拒绝当前加锁,而不管当下的线程是哪个,就会产生死锁;
如果是可重入锁,则是会让这个锁保存,是哪个线程加上的锁,后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前持有这把锁的线程,灵活判定;
实际上,synchronized本身就是一个可重入锁,不会产生死锁;
锁什么时候释放?
让锁拥有一个计数器:
让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整型变量记录当前
线程加了几次锁
每遇到一个加锁操作,计数器加1;
每遇到一个解锁操作,计数器减1;
当减到0的时候,才真正执行释放锁操作,其他时候不释放
也叫做 引用计数。