目录
5、面试题:线程池中阻塞队列作用?为什么是先添加列队而不是先创建最大线程?
一、单例模式
1、什么是单例模式
单例模式是一种常用的软件设计模式,其定义是单例对象的类只能允许一个实例存在。
许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。
2、应用场景
举一个例子,网站的计数器,一般也是采用单例模式实现,如果你存在多个计数器,每一个用户的访问都刷新计数器的值,这样的话你的实计数的值是难以同步的。但是如果采用单例模式实现就不会存在这样的问题,而且还可以避免线程安全问题。同样多线程的线程池的设计一般也是采用单例模式,这是由于线程池需要方便对池中的线程进行控制
适用场景:
- 需要生成唯一序列的环境
- 需要频繁实例化然后销毁的对象。
- 创建对象时耗时过多或者耗资源过多,但又经常用到的对象。
- 方便资源相互通信的环境
3、优缺点
优点:
- 在内存中只有一个对象,节省内存空间;
- 避免频繁的创建销毁对象,可以提高性能;
- 避免对共享资源的多重占用,简化访问;
- 为整个系统提供一个全局访问点。
缺点:
- 不适用于变化频繁的对象;
- 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共 享连接池对象的程序过多而出现连接池溢出;
- 如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失;
4、代码实现
饿汉单例模式
public class Singleton1 {
private static Singleton1 instance = new Singleton1();
private Singleton1(){}
public static Singleton1 getInstance(){
return instance;
}
}
类加载的方式是按需加载,且加载一次。。因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用;而且,由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例,这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。
懒汉单例模式
public class Singleton2 {
private static Singleton2 instance;
private Singleton2(){}
public static Singleton2 getInstance(){
if (instance == null) {
instance = new Singleton2();
}
return instance;
}
}
我们从懒汉式单例可以看到,单例实例被延迟加载,即只有在真正使用的时候才会实例化一个对象并交给自己的引用。
这种写法起到了Lazy Loading的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多线程环境下不可使用这种方式。
双锁单例模式
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
双锁模式,进行了两次的判断,第一次是判断是否要加锁,第二次是判断是否要创建实例。由于singleton=new Singleton()对象的创建在JVM中可能会进行重排序,在多线程访问下存在风险,使用volatile修饰signleton实例变量有效,解决该问题。
5、小结
从这几种实现中,我们可以总结出,要想实现效率高的线程安全的单例,我们必须注意以下两点:
- 尽量减少同步块的作用域;
- 尽量使用细粒度的锁。
二、阻塞队列
1、阻塞队列是什么?
阻塞队列同样遵守“先进先出”的原则
阻塞队列的一种线程安全的数据结构,具有以下特性:
- 当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素
- 当队列空的时候,继续出队列就会阻塞,直到有其他线程往队列中插入元素
2、阻塞队列的应用场景
生产者消费者模型——指的是多线程协同工作的一种方式
例如:流水线工作中,A处处理的半成品,需要送往B处进行再次处理,此时A、B就是两个不同的线程,如图不使用消息队列:
劣势:A、B之间的衔接,通信时,耦合度比较高,处理代码A、B时,必须知道彼此的存在,倘若代码已经写好了,此时又要加上一个与B同级的C处理,那么此时,还必须得修改A处的代码
使用阻塞队列的优势:
(1、使用阻塞队列,有利于代码的“解耦合”
如图:
使用阻塞队列作为A、B之间的一个交易场所,此时处理A、B代码时,就不需要考虑对方了,A只管往阻塞队列中插入数据,B只管取出元素
(2、削峰填谷
当A处理的产品突增时,
使用阻塞队列:
使用生产者消费者模型,那么即使A请求暴涨,也不会影响到B,顶多A挂了,应用服务器不会受到影响,这是因为A请求暴涨后,用户的请求都被打包到阻塞队列中(如果阻塞队列有界,则会引起队列阻塞,不会影响到B),B还是以相同的速度处理这些请求,所以生产者消费者模型可以起到“削峰填谷”的作用。
3、阻塞队列的具体使用
(1、标准库的阻塞队列
public class Test1 {
public static void main(String[] args) throws InterruptedException {
//队列的三个基本操作,入队列,出队列,取队首元素
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
//入队列,put带有阻塞功能,而offer没有
queue.put(1);
queue.put(2);
queue.put(3);
//出队列
System.out.println("出队列:"+queue.take());
//阻塞队列没有提供“带有阻塞功能”的取队首元素的方法
}
}
(2、使用循环队列实现阻塞队列
class MyBlockingQueue {
private int[] item = new int[1000];
private volatile int head = 0;//记录队首
private volatile int tail = 0;//记录队尾
private volatile int size = 0;//记录元素个数
//入队列
public void put(int elem) throws InterruptedException {
synchronized (this) {
//判断队列是否满了
while (size >= item.length) {
//return;
//阻塞
this.wait();
}
//未满
item[tail] = elem;
tail++;
size++;
if (tail >= item.length) {
tail = 0;
}
this.notify();
}
}
//出队列
public Integer take() throws InterruptedException {
synchronized (this) {
//判断队列是否为空
while (size == 0) {
//return null;
//阻塞
this.wait();
}
//不为空
int val = item[head];
head++;
if (head >= item.length) {
head = 0;
}
size--;
this.notify();
return val;
}
}
}
三、定时器
1、定时器是什么?
定时器其实就相当于一个闹钟,当闹钟响了,就执行相应的任务
2、标准库中的定时器
public class Test1 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("时间到,开始执行任务1");//run()方法里面是要执行的任务
}
},3000);//3000是自己任意指定的时间间隔
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("时间到,开始执行任务2");
}
},5000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("时间到,执行任务3");
}
},1000);
System.out.println("开始计时");
}
}
运行结果:
3、手动实现一个定时器
(1、首先呢,创建一个类,表示任务:
class MyTask {
//任务
private Runnable runnable;
//时间
private long time;
public MyTask(Runnable runnable,long time) {
this.runnable = runnable;
this.time = System.currentTimeMillis()+time;
}
}
(2、模拟一个Timer类:
class MyTimer {
public void schedule(Runnable runnable,long time) {
MyTask myTask = new MyTask(runnable,time);
}
}
模拟到这里,一个大致框架已经出来了,接下来,我们需要思考,如何去管这些任务,根据时间的前后顺序来依次执行任务,我们都会想到可以使用优先级队列来实现 ,没错,确实是这样子的,但同时我们需要注意一个点,优先级队列是线程不安全的,为保证线程安全,我们采用以下方式(结合阻塞队列):
private BlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
(3、创一个扫描线程,检查队首元素的时间是否到了,如果到了,则执行该任务
public MyTimer() {
Thread t = new Thread(() -> {
while(true) {
//取出队首元素
try {
MyTask myTask = queue.take();
if(myTask.getTime() <= System.currentTimeMillis()) {
//到点执行任务
myTask.getRunnable().run();
} else {
queue.put(myTask);//塞回队列
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
}
(4、添加对象之间比较的方法
public int compareTo(MyTask o) {
return (int)(this.time - o.time);
}
大致这样算是模拟出来了
(5、优化
优化一:线程安全问题
优化二:上述代码中循环判断任务执行的时间是否到了,导致CPU没有空做其他时间
完整代码:
//用这个类表示一个任务
class MyTask implements Comparable<MyTask> {
//任务
private Runnable runnable;
//时间
private long time;
public MyTask(Runnable runnable,long time) {
this.runnable = runnable;
this.time = System.currentTimeMillis()+time;
}
public Runnable getRunnable() {
return runnable;
}
public long getTime() {
return time;
}
public int compareTo(MyTask o) {
return (int)(this.time - o.time);
}
}
class MyTimer {
private BlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
private Object locker = new Object();
public MyTimer() {
Thread t = new Thread(() -> {
while(true) {
//取出队首元素
try {
synchronized (locker) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (curTime >= myTask.getTime()) {
//到点执行任务
myTask.getRunnable().run();
} else {
queue.put(myTask);//塞回队列
locker.wait(myTask.getTime() - curTime);
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
}
public void schedule(Runnable runnable,long time) throws InterruptedException {
MyTask myTask = new MyTask(runnable,time);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
}
public class Test3 {
public static void main(String[] args) throws InterruptedException {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("时间到执行任务1");
}
},1000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("时间到执行任务2");
}
},1500);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("时间到执行任务3");
}
},500);
}
}
四、线程池
1、什么是线程池?
理解池——池,就是鱼塘咯!比如,一家柠檬鱼的饭店,用鱼量比较大,每次有客人来饭店吃饭,都需要派人去买鱼,那就太浪费人力物力和时间了。怎么解决呢?在饭店的后院修一个小鱼塘,一次性买个1000条鱼放在鱼塘里,来客人了,直接从鱼塘中拿鱼,节省时间,节省开销了。
同理,线程池就是池子里面放很多已经创建好了的线程,使用时,直接从池子中取出它,使用即可,使用完了,将线程还回到池子中
使用线程池是纯用户态操作,要比创建线程(经历内核态)要快
为什么用线程池?
- 降低资源消耗:提高线程利用率,降低创建和销毁线程的消耗
- 提高响应速度 :任务来了,直接有线程可用可执行,而不是先创建线程,再执行
- 提高线程的可管理性:线程是稀缺资源,使用线程池可以统一调优监控
2、 Java线程池标准类
java也提供了相关行程池的标准类ThreadPoolExecutor,也被称作多线程执行器,该类里面的线程包括两类,一类是核心线程,另一类是非核心线程,当核心线程全部跑满了还不能满足程序运行的需求,就会启用非核心线程,直到任务量少了,慢慢地,非核心线程也就退役了,通俗一点核心线程就相当于公司里面的正式工,非核心线程相当于临时工,当公司人手不够的时候就会请临时工来助力工作,当员工富余了,公司就会将临时工辞退。
jdk8中,提供了4个构造方法,我主要介绍参数最多的那一个构造方法,其他3个构造方法都是基于此构造方法减少了参数,所以搞懂最多参数的构造方法,其他构造方法也就明白了。
//参数最多的一个构造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize表示核心线程数。
- maximumPoolSize表示最大线程数,就是核心线程数与非核心线程数之和。
- keepAliveTime非核心线程最长等待新任务的时,就是非核心线程的最长摸鱼时间,超过此时间,该线程就会被停用。
- unit 时间单位。
- workQueue任务队列,通过submit方法将任务注册到该队列中。
- threadFactory线程工厂,线程创建的方案。
- handler拒绝策略,由于达到线程边界和队列容量而阻止执行时使用的处理策略。
另几个构造方法:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue)
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory)
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
重点(面试问题):
那核心线程数最合适值是多少呢?假设CPU有N核心,最适核心线程数是N?是2N?是1.5N?只要你能够说出一个具体的数,那就错了,最适的核心线程数要视情况而定,没有一个绝对的标准的值。
在具体使用线程池时,往往使用的是Executor,因为 Executor是 ThreadPoolExecutor所实现的一个接口,由于标准库中的线程池使用较复杂,对于ThreadPoolExecutor类中的方法我们就不介绍了,最重要的一个方法是submit方法,这个方法能够将任务交给线程池去执行,接下来我们来理一理线程池最基本的工作原理,我们来尝试实现一个简单的线程池(继续往下看)。
3、标准库中的线程池
public class Test {
public static void main(String[] args) {
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("任务");
}
});
}
}
4、手动实现一个线程池
4.1、线程池的基本工作原理
线程池是通过管理一系列的线程来执行程序员所传入的任务,这些任务被放在线程池对象中的一个阻塞队列中,然后线程池会调度线程来执行这些任务,优先调度核心线程(核心线程会在线程池对象构造时全部创建),如果核心线程不够用了,就会创建非核心线程来帮忙处理任务,当非核心线程一定的时间没有收到新任务时,非核心线程就会被销毁,我们实现线程池的目的是加深对线程池的理解,所以实现的过程中就不去实现非核心线程了,线程池里面的线程全部以核心线程的形式实现。
我们需要实现一个线程池,根据以上的原理需要准备:
- 任务,可以使用Runnable。
- 组织任务的数据结构,可以使用阻塞对列。
- 工作线程(核心线程)的实现。
- 组织线程的数据结构,可以使用List。
- 新增任务的方法
submit
。
4.2、 线程池的简单实现
关于任务和任务的组织就不用多说了,直接使用Runnable和阻塞队列BlockingQueue<Runnable>就可以了,重点说一下工作线程如何描述的,工作线程中需要有一个阻塞队列引用来获取我们存任务的那一个阻塞队列对象,然后重写run方法通过循环不断的获取任务执行任务。
然后根据传入的核心线程数来创建并启动工作线程,将这些线程放入顺序表或链表中,便于管理。
最后就是创建一个submit方法用来给用户或程序员派发任务到阻塞队列,这样线程池中的线程就会去执行我们所传入的任务了
代码:
class MyThreadPool {
//1.需要一个类来描述具体的任务,直接使用Runnable即可
//2.有了任务,我们需要将多个任务组织起来,可以使用阻塞队列
private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//3.组织好任务,就可以分配线程池中的线程来执行任务了,所以我们需要描述线程,专门来执行任务
static class Worker extends Thread {
//获取任务队列
private final BlockingQueue<Runnable> queue;
//构造线程时需要将任务队列初始化
public Worker(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
//重写线程中的run方法,用来执行阻塞队列中的任务
@Override
public void run() {
while (true) {
try {
//获取任务
Runnable runnable = queue.take();
//执行任务
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 4.线程池中肯定存在不止一个线程,所以我们需要对线程进行组织,这里我们可以使用顺序表,使用链表也可以
private final List<Worker> workers = new ArrayList<>();
//根据构造方法指定的线程数将线程存入workers中
public MyThreadPool(int threadNums) {
for (int i = 0; i < threadNums; i++) {
Worker worker = new Worker(this.queue);
worker.start();
this.workers.add(worker);
}
}
// 5.创建一个方法,用来将任务存放到线程池中
public void submit(Runnable runnable) {
try {
this.queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
5、面试题:线程池中阻塞队列作用?为什么是先添加列队而不是先创建最大线程?
(1)、一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前任务了,就无法保留当前的任务了,阻塞队列通过阻塞可以保留当前想要继续入队的任务
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,得到线程进入wait状态,释放CPU资源
阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用CPU资源
(2)、在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率.
6、拒绝策略:(重点)
static class | ThreadPoolExecutor.AbortPolicy 被拒绝的任务的处理程序,抛出一个 (好比领导给员工安排了很多活,加班都干不完,这个员工干不下去了,一瞬间就情绪崩溃,晕倒了;) |
static class | ThreadPoolExecutor.CallerRunsPolicy ( 好比领导给员工安排了很多任务,员工干不过来,员工就给领导说,你来干吧,这时候领导自己干,如果能干他就干了,不能干,就丢弃这个任务了;) |
static class | ThreadPoolExecutor.DiscardOldestPolicy (好比领导个员工安排了任务1、任务2、任务3,领导安排到任务4的时候,员工说谈干不了,领导就说,没事,任务1不着急,你先干任务4;) |
static class | ThreadPoolExecutor.DiscardPolicy 被拒绝的任务的处理程序静默地丢弃被拒绝的任务。 (好比领导个员工安排了任务1、任务2、任务3,领导安排到任务4的时候,员工说谈干不了,领导说,没事,任务4不着急,你先干之前的吧;) |